DOM element dimension noise (offsetWidth/Height etc.) broke complex web apps like Discord by returning fractional values where integers are expected. Removed entirely — measureText noise is sufficient for font enumeration. Changed document.fonts.check() to return true (uniform response) instead of false, which caused font loading logic to hang in apps waiting for fonts.
1152 lines
41 KiB
HTML
1152 lines
41 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>ContainSite Fingerprint Test</title>
|
|
<style>
|
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
body {
|
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, monospace;
|
|
background: #0d1117; color: #c9d1d9; padding: 24px; line-height: 1.5;
|
|
}
|
|
h1 { color: #58a6ff; margin-bottom: 4px; font-size: 22px; }
|
|
.subtitle { color: #8b949e; margin-bottom: 20px; font-size: 13px; }
|
|
.hash-banner {
|
|
background: #161b22; border: 1px solid #30363d; border-radius: 8px;
|
|
padding: 16px; margin-bottom: 20px; text-align: center;
|
|
}
|
|
.hash-banner .label { color: #8b949e; font-size: 12px; text-transform: uppercase; letter-spacing: 1px; }
|
|
.hash-banner .hash { color: #f0883e; font-size: 20px; font-family: monospace; margin-top: 4px; word-break: break-all; }
|
|
.hash-banner .hint { color: #8b949e; font-size: 11px; margin-top: 8px; }
|
|
.section {
|
|
background: #161b22; border: 1px solid #30363d; border-radius: 8px;
|
|
margin-bottom: 16px; overflow: hidden;
|
|
}
|
|
.section-header {
|
|
padding: 12px 16px; border-bottom: 1px solid #30363d;
|
|
display: flex; align-items: center; gap: 10px;
|
|
}
|
|
.section-header h2 { font-size: 14px; color: #c9d1d9; }
|
|
.status-dot {
|
|
width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0;
|
|
}
|
|
.status-dot.pass { background: #3fb950; }
|
|
.status-dot.fail { background: #f85149; }
|
|
.status-dot.warn { background: #d29922; }
|
|
.status-dot.pending { background: #484f58; }
|
|
.section-body { padding: 12px 16px; }
|
|
.row {
|
|
display: flex; justify-content: space-between; align-items: baseline;
|
|
padding: 4px 0; border-bottom: 1px solid #21262d; font-size: 13px;
|
|
}
|
|
.row:last-child { border-bottom: none; }
|
|
.row .key { color: #8b949e; }
|
|
.row .val { color: #c9d1d9; font-family: monospace; text-align: right; max-width: 60%; word-break: break-all; }
|
|
.row .val.spoofed { color: #3fb950; }
|
|
.row .val.real { color: #f85149; }
|
|
canvas { display: none; }
|
|
.canvas-preview { display: flex; gap: 12px; margin-top: 8px; }
|
|
.canvas-preview canvas { display: block; border: 1px solid #30363d; border-radius: 4px; }
|
|
.summary-bar {
|
|
display: flex; gap: 16px; padding: 12px 16px; background: #161b22;
|
|
border: 1px solid #30363d; border-radius: 8px; margin-bottom: 20px;
|
|
font-size: 13px;
|
|
}
|
|
.summary-bar .item { display: flex; align-items: center; gap: 6px; }
|
|
.copy-btn {
|
|
background: #21262d; color: #c9d1d9; border: 1px solid #30363d;
|
|
padding: 6px 14px; border-radius: 6px; cursor: pointer; font-size: 12px;
|
|
margin-top: 12px;
|
|
}
|
|
.copy-btn:hover { background: #30363d; }
|
|
#webrtc-results .val { font-size: 12px; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<h1>ContainSite Fingerprint Test</h1>
|
|
<p class="subtitle">Open this page in multiple container tabs and compare the composite hash below.</p>
|
|
|
|
<div class="hash-banner">
|
|
<div class="label">Composite Fingerprint Hash</div>
|
|
<div class="hash" id="composite-hash">Computing...</div>
|
|
<div class="hint">This hash should be DIFFERENT in each container tab. If it's the same, spoofing is not working.</div>
|
|
</div>
|
|
|
|
<div class="summary-bar" id="summary-bar">
|
|
<div class="item"><span class="status-dot pending" id="sum-pass"></span> <span id="sum-pass-n">-</span> Spoofed</div>
|
|
<div class="item"><span class="status-dot pending" id="sum-fail"></span> <span id="sum-fail-n">-</span> Not Spoofed</div>
|
|
<div class="item"><span class="status-dot pending" id="sum-warn"></span> <span id="sum-warn-n">-</span> Warning</div>
|
|
</div>
|
|
|
|
<!-- Navigator -->
|
|
<div class="section" id="sec-navigator">
|
|
<div class="section-header"><span class="status-dot pending" id="dot-navigator"></span><h2>Navigator</h2></div>
|
|
<div class="section-body" id="navigator-results"></div>
|
|
</div>
|
|
|
|
<!-- Screen -->
|
|
<div class="section" id="sec-screen">
|
|
<div class="section-header"><span class="status-dot pending" id="dot-screen"></span><h2>Screen</h2></div>
|
|
<div class="section-body" id="screen-results"></div>
|
|
</div>
|
|
|
|
<!-- Canvas -->
|
|
<div class="section" id="sec-canvas">
|
|
<div class="section-header"><span class="status-dot pending" id="dot-canvas"></span><h2>Canvas</h2></div>
|
|
<div class="section-body" id="canvas-results"></div>
|
|
</div>
|
|
|
|
<!-- WebGL -->
|
|
<div class="section" id="sec-webgl">
|
|
<div class="section-header"><span class="status-dot pending" id="dot-webgl"></span><h2>WebGL</h2></div>
|
|
<div class="section-body" id="webgl-results"></div>
|
|
</div>
|
|
|
|
<!-- Audio -->
|
|
<div class="section" id="sec-audio">
|
|
<div class="section-header"><span class="status-dot pending" id="dot-audio"></span><h2>Audio</h2></div>
|
|
<div class="section-body" id="audio-results"></div>
|
|
</div>
|
|
|
|
<!-- Timezone -->
|
|
<div class="section" id="sec-timezone">
|
|
<div class="section-header"><span class="status-dot pending" id="dot-timezone"></span><h2>Timezone</h2></div>
|
|
<div class="section-body" id="timezone-results"></div>
|
|
</div>
|
|
|
|
<!-- Fonts -->
|
|
<div class="section" id="sec-fonts">
|
|
<div class="section-header"><span class="status-dot pending" id="dot-fonts"></span><h2>Fonts (measureText)</h2></div>
|
|
<div class="section-body" id="fonts-results"></div>
|
|
</div>
|
|
|
|
<!-- ClientRects -->
|
|
<div class="section" id="sec-rects">
|
|
<div class="section-header"><span class="status-dot pending" id="dot-rects"></span><h2>ClientRects</h2></div>
|
|
<div class="section-body" id="rects-results"></div>
|
|
</div>
|
|
|
|
<!-- Plugins -->
|
|
<div class="section" id="sec-plugins">
|
|
<div class="section-header"><span class="status-dot pending" id="dot-plugins"></span><h2>Plugins</h2></div>
|
|
<div class="section-body" id="plugins-results"></div>
|
|
</div>
|
|
|
|
<!-- Battery -->
|
|
<div class="section" id="sec-battery">
|
|
<div class="section-header"><span class="status-dot pending" id="dot-battery"></span><h2>Battery</h2></div>
|
|
<div class="section-body" id="battery-results"></div>
|
|
</div>
|
|
|
|
<!-- Connection -->
|
|
<div class="section" id="sec-connection">
|
|
<div class="section-header"><span class="status-dot pending" id="dot-connection"></span><h2>Connection</h2></div>
|
|
<div class="section-body" id="connection-results"></div>
|
|
</div>
|
|
|
|
<!-- WebRTC -->
|
|
<div class="section" id="sec-webrtc">
|
|
<div class="section-header"><span class="status-dot pending" id="dot-webrtc"></span><h2>WebRTC Leak Test</h2></div>
|
|
<div class="section-body" id="webrtc-results"></div>
|
|
</div>
|
|
|
|
<!-- HTTP Headers -->
|
|
<div class="section" id="sec-headers">
|
|
<div class="section-header"><span class="status-dot pending" id="dot-headers"></span><h2>HTTP Headers (UA + Accept-Language)</h2></div>
|
|
<div class="section-body" id="headers-results"></div>
|
|
</div>
|
|
|
|
<!-- Speech Synthesis -->
|
|
<div class="section" id="sec-speech">
|
|
<div class="section-header"><span class="status-dot pending" id="dot-speech"></span><h2>Speech Synthesis</h2></div>
|
|
<div class="section-body" id="speech-results"></div>
|
|
</div>
|
|
|
|
<!-- matchMedia -->
|
|
<div class="section" id="sec-matchmedia">
|
|
<div class="section-header"><span class="status-dot pending" id="dot-matchmedia"></span><h2>matchMedia Screen Queries</h2></div>
|
|
<div class="section-body" id="matchmedia-results"></div>
|
|
</div>
|
|
|
|
<!-- Performance -->
|
|
<div class="section" id="sec-perf">
|
|
<div class="section-header"><span class="status-dot pending" id="dot-perf"></span><h2>Performance Timing</h2></div>
|
|
<div class="section-body" id="perf-results"></div>
|
|
</div>
|
|
|
|
<!-- Storage Estimate -->
|
|
<div class="section" id="sec-storage">
|
|
<div class="section-header"><span class="status-dot pending" id="dot-storage"></span><h2>Storage Estimate</h2></div>
|
|
<div class="section-body" id="storage-results"></div>
|
|
</div>
|
|
|
|
<!-- Font Enumeration (DOM) -->
|
|
<div class="section" id="sec-fontdom">
|
|
<div class="section-header"><span class="status-dot pending" id="dot-fontdom"></span><h2>Font Enumeration (DOM Dimensions)</h2></div>
|
|
<div class="section-body" id="fontdom-results"></div>
|
|
</div>
|
|
|
|
<!-- document.fonts -->
|
|
<div class="section" id="sec-docfonts">
|
|
<div class="section-header"><span class="status-dot pending" id="dot-docfonts"></span><h2>document.fonts API</h2></div>
|
|
<div class="section-body" id="docfonts-results"></div>
|
|
</div>
|
|
|
|
<!-- Client Hints -->
|
|
<div class="section" id="sec-clienthints">
|
|
<div class="section-header"><span class="status-dot pending" id="dot-clienthints"></span><h2>Client Hints Headers</h2></div>
|
|
<div class="section-body" id="clienthints-results"></div>
|
|
</div>
|
|
|
|
<!-- Gamepad API -->
|
|
<div class="section" id="sec-gamepad">
|
|
<div class="section-header"><span class="status-dot pending" id="dot-gamepad"></span><h2>Gamepad API</h2></div>
|
|
<div class="section-body" id="gamepad-results"></div>
|
|
</div>
|
|
|
|
<!-- WebGL readPixels -->
|
|
<div class="section" id="sec-readpixels">
|
|
<div class="section-header"><span class="status-dot pending" id="dot-readpixels"></span><h2>WebGL readPixels</h2></div>
|
|
<div class="section-body" id="readpixels-results"></div>
|
|
</div>
|
|
|
|
<button class="copy-btn" onclick="copyReport()">Copy Full Report to Clipboard</button>
|
|
|
|
<canvas id="test-canvas" width="300" height="60"></canvas>
|
|
<span id="rect-probe" style="position:absolute;visibility:hidden;font-size:16px;">ABCDEFghijklmnop</span>
|
|
<span id="fontdom-probe" style="position:absolute;visibility:hidden;font-size:72px;font-family:monospace;">mmmmmmmmmmmmmmm</span>
|
|
|
|
<script>
|
|
// Utility: simple hash for display (works on HTTP — no crypto.subtle needed)
|
|
function simpleHash(str) {
|
|
let h1 = 0xdeadbeef, h2 = 0x41c6ce57;
|
|
for (let i = 0; i < str.length; i++) {
|
|
const ch = str.charCodeAt(i);
|
|
h1 = Math.imul(h1 ^ ch, 2654435761);
|
|
h2 = Math.imul(h2 ^ ch, 1597334677);
|
|
}
|
|
h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
|
|
h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
|
|
return (h2 >>> 0).toString(16).padStart(8, "0") + (h1 >>> 0).toString(16).padStart(8, "0");
|
|
}
|
|
|
|
async function sha256Short(str) {
|
|
// Use crypto.subtle if available (HTTPS), otherwise fall back to simpleHash
|
|
if (crypto.subtle) {
|
|
try {
|
|
const buf = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(str));
|
|
const arr = Array.from(new Uint8Array(buf));
|
|
return arr.map(b => b.toString(16).padStart(2, "0")).join("").slice(0, 16);
|
|
} catch(e) {}
|
|
}
|
|
return simpleHash(str);
|
|
}
|
|
|
|
// Utility: wrap async test with a timeout
|
|
function withTimeout(fn, ms) {
|
|
return Promise.race([
|
|
fn(),
|
|
new Promise((_, reject) => setTimeout(() => reject(new Error("Timed out after " + ms + "ms")), ms))
|
|
]);
|
|
}
|
|
|
|
function row(key, val, cls) {
|
|
return `<div class="row"><span class="key">${key}</span><span class="val ${cls || ''}">${val}</span></div>`;
|
|
}
|
|
|
|
function setDot(id, status) {
|
|
const el = document.getElementById(id);
|
|
el.className = "status-dot " + status;
|
|
}
|
|
|
|
const report = {};
|
|
const sectionStatus = {};
|
|
|
|
// ── Navigator ──
|
|
function testNavigator() {
|
|
const el = document.getElementById("navigator-results");
|
|
const n = navigator;
|
|
const vals = {
|
|
"userAgent": n.userAgent,
|
|
"platform": n.platform,
|
|
"oscpu": n.oscpu || "(undefined)",
|
|
"appVersion": n.appVersion,
|
|
"hardwareConcurrency": n.hardwareConcurrency,
|
|
"deviceMemory": n.deviceMemory || "(undefined)",
|
|
"maxTouchPoints": n.maxTouchPoints,
|
|
"language": n.language,
|
|
"languages": JSON.stringify(n.languages),
|
|
};
|
|
report.navigator = vals;
|
|
|
|
let html = "";
|
|
for (const [k, v] of Object.entries(vals)) {
|
|
html += row(k, v);
|
|
}
|
|
el.innerHTML = html;
|
|
|
|
// If platform matches real system, likely not spoofed
|
|
const spoofed = n.platform !== "Linux x86_64" || n.hardwareConcurrency !== 4;
|
|
sectionStatus.navigator = spoofed ? "pass" : "fail";
|
|
setDot("dot-navigator", sectionStatus.navigator);
|
|
}
|
|
|
|
// ── Screen ──
|
|
function testScreen() {
|
|
const el = document.getElementById("screen-results");
|
|
const vals = {
|
|
"screen.width": screen.width,
|
|
"screen.height": screen.height,
|
|
"screen.availWidth": screen.availWidth,
|
|
"screen.availHeight": screen.availHeight,
|
|
"screen.colorDepth": screen.colorDepth,
|
|
"screen.pixelDepth": screen.pixelDepth,
|
|
"window.outerWidth": window.outerWidth,
|
|
"window.outerHeight": window.outerHeight,
|
|
"window.innerWidth": window.innerWidth,
|
|
"window.innerHeight": window.innerHeight,
|
|
};
|
|
report.screen = vals;
|
|
|
|
let html = "";
|
|
for (const [k, v] of Object.entries(vals)) {
|
|
html += row(k, v);
|
|
}
|
|
el.innerHTML = html;
|
|
|
|
const spoofed = screen.width !== 1920 || screen.height !== 1080;
|
|
sectionStatus.screen = spoofed ? "pass" : "fail";
|
|
setDot("dot-screen", sectionStatus.screen);
|
|
}
|
|
|
|
// ── Canvas ──
|
|
async function testCanvas() {
|
|
const el = document.getElementById("canvas-results");
|
|
const c = document.getElementById("test-canvas");
|
|
const ctx = c.getContext("2d");
|
|
|
|
// Draw a standard test pattern
|
|
ctx.fillStyle = "#f06d06";
|
|
ctx.fillRect(0, 0, 300, 60);
|
|
ctx.fillStyle = "#1a1a2e";
|
|
ctx.font = "18px Arial";
|
|
ctx.fillText("ContainSite Canvas Test 🎨", 10, 35);
|
|
ctx.strokeStyle = "#16213e";
|
|
ctx.beginPath();
|
|
ctx.arc(250, 30, 20, 0, Math.PI * 2);
|
|
ctx.stroke();
|
|
|
|
const dataUrl = c.toDataURL();
|
|
const hash = await sha256Short(dataUrl);
|
|
|
|
report.canvas = { hash, dataUrl_tail: dataUrl.slice(-40) };
|
|
|
|
el.innerHTML = row("Canvas hash", hash) +
|
|
`<div class="canvas-preview"><canvas id="vis-canvas" width="300" height="60" style="display:block"></canvas></div>`;
|
|
|
|
// Redraw on visible canvas
|
|
const vc = document.getElementById("vis-canvas");
|
|
const vctx = vc.getContext("2d");
|
|
vctx.fillStyle = "#f06d06";
|
|
vctx.fillRect(0, 0, 300, 60);
|
|
vctx.fillStyle = "#1a1a2e";
|
|
vctx.font = "18px Arial";
|
|
vctx.fillText("ContainSite Canvas Test", 10, 35);
|
|
vctx.strokeStyle = "#16213e";
|
|
vctx.beginPath();
|
|
vctx.arc(250, 30, 20, 0, Math.PI * 2);
|
|
vctx.stroke();
|
|
|
|
// We can't definitively know if it's spoofed without a baseline,
|
|
// but we report the hash for cross-container comparison
|
|
sectionStatus.canvas = "pass"; // hash present = vector active (user compares manually)
|
|
setDot("dot-canvas", "pass");
|
|
}
|
|
|
|
// ── WebGL ──
|
|
function testWebGL() {
|
|
const el = document.getElementById("webgl-results");
|
|
const c = document.createElement("canvas");
|
|
const gl = c.getContext("webgl") || c.getContext("experimental-webgl");
|
|
if (!gl) {
|
|
el.innerHTML = row("Status", "WebGL not available", "fail");
|
|
sectionStatus.webgl = "warn";
|
|
setDot("dot-webgl", "warn");
|
|
return;
|
|
}
|
|
|
|
const dbgExt = gl.getExtension("WEBGL_debug_renderer_info");
|
|
const vendor = dbgExt ? gl.getParameter(dbgExt.UNMASKED_VENDOR_WEBGL) : "(ext not available)";
|
|
const renderer = dbgExt ? gl.getParameter(dbgExt.UNMASKED_RENDERER_WEBGL) : "(ext not available)";
|
|
|
|
const vals = { vendor, renderer };
|
|
report.webgl = vals;
|
|
|
|
el.innerHTML = row("Unmasked Vendor", vendor) + row("Unmasked Renderer", renderer);
|
|
|
|
// Check if it matches known spoofed patterns from the extension
|
|
const knownSpoofed = /ANGLE|Mesa|Apple M/i.test(renderer) && !/llvmpipe/i.test(renderer);
|
|
sectionStatus.webgl = knownSpoofed ? "pass" : "warn";
|
|
setDot("dot-webgl", sectionStatus.webgl);
|
|
}
|
|
|
|
// ── Audio ──
|
|
async function testAudio() {
|
|
const el = document.getElementById("audio-results");
|
|
try {
|
|
await withTimeout(async () => {
|
|
const ctx = new (window.OfflineAudioContext || window.webkitOfflineAudioContext)(1, 44100, 44100);
|
|
const osc = ctx.createOscillator();
|
|
osc.type = "triangle";
|
|
osc.frequency.setValueAtTime(10000, ctx.currentTime);
|
|
const comp = ctx.createDynamicsCompressor();
|
|
comp.threshold.setValueAtTime(-50, ctx.currentTime);
|
|
comp.knee.setValueAtTime(40, ctx.currentTime);
|
|
comp.ratio.setValueAtTime(12, ctx.currentTime);
|
|
comp.attack.setValueAtTime(0, ctx.currentTime);
|
|
comp.release.setValueAtTime(0.25, ctx.currentTime);
|
|
osc.connect(comp);
|
|
comp.connect(ctx.destination);
|
|
osc.start(0);
|
|
const rendered = await ctx.startRendering();
|
|
const data = rendered.getChannelData(0);
|
|
|
|
let sum = 0;
|
|
for (let i = 4500; i < 5000; i++) sum += Math.abs(data[i]);
|
|
const audioHash = await sha256Short(sum.toString());
|
|
|
|
report.audio = { sampleSum: sum, hash: audioHash };
|
|
el.innerHTML = row("Audio fingerprint hash", audioHash) + row("Sample sum (4500-5000)", sum.toFixed(10));
|
|
sectionStatus.audio = "pass";
|
|
setDot("dot-audio", "pass");
|
|
}, 3000);
|
|
} catch (e) {
|
|
report.audio = { error: e.message };
|
|
el.innerHTML = row("Error", e.message, "fail");
|
|
sectionStatus.audio = "warn";
|
|
setDot("dot-audio", "warn");
|
|
}
|
|
}
|
|
|
|
// ── Timezone ──
|
|
function testTimezone() {
|
|
const el = document.getElementById("timezone-results");
|
|
const offset = new Date().getTimezoneOffset();
|
|
const resolved = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
const dateStr = new Date().toString();
|
|
|
|
const vals = {
|
|
"getTimezoneOffset()": offset,
|
|
"Intl timeZone": resolved,
|
|
"Date.toString()": dateStr,
|
|
};
|
|
report.timezone = vals;
|
|
|
|
let html = "";
|
|
for (const [k, v] of Object.entries(vals)) html += row(k, v);
|
|
el.innerHTML = html;
|
|
|
|
// Real system timezone is likely the local one — just report it
|
|
sectionStatus.timezone = "pass";
|
|
setDot("dot-timezone", "pass");
|
|
}
|
|
|
|
// ── Fonts ──
|
|
function testFonts() {
|
|
const el = document.getElementById("fonts-results");
|
|
const c = document.createElement("canvas");
|
|
const ctx = c.getContext("2d");
|
|
|
|
const testFonts = ["monospace", "sans-serif", "serif", "Arial", "Courier New", "Georgia"];
|
|
const results = {};
|
|
for (const font of testFonts) {
|
|
ctx.font = `16px ${font}`;
|
|
const m = ctx.measureText("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789");
|
|
results[font] = m.width;
|
|
}
|
|
|
|
report.fonts = results;
|
|
let html = "";
|
|
for (const [font, w] of Object.entries(results)) {
|
|
// Sub-pixel values (non-integer) suggest noise was added
|
|
const hasFraction = w % 1 !== 0;
|
|
html += row(font, w.toFixed(6), hasFraction ? "spoofed" : "");
|
|
}
|
|
el.innerHTML = html;
|
|
|
|
const anyFractional = Object.values(results).some(w => w % 1 !== 0);
|
|
sectionStatus.fonts = anyFractional ? "pass" : "warn";
|
|
setDot("dot-fonts", sectionStatus.fonts);
|
|
}
|
|
|
|
// ── ClientRects ──
|
|
function testRects() {
|
|
const el = document.getElementById("rects-results");
|
|
const probe = document.getElementById("rect-probe");
|
|
const rect = probe.getBoundingClientRect();
|
|
|
|
const vals = {
|
|
"x": rect.x,
|
|
"y": rect.y,
|
|
"width": rect.width,
|
|
"height": rect.height,
|
|
"top": rect.top,
|
|
"left": rect.left,
|
|
};
|
|
report.rects = vals;
|
|
|
|
let html = "";
|
|
for (const [k, v] of Object.entries(vals)) {
|
|
const hasFraction = v % 1 !== 0;
|
|
html += row(k, v.toFixed(6), hasFraction ? "spoofed" : "");
|
|
}
|
|
el.innerHTML = html;
|
|
|
|
// If noise is applied, values will have non-trivial fractional parts
|
|
const anyNoisy = Object.values(vals).some(v => {
|
|
const frac = Math.abs(v % 1);
|
|
return frac > 0.001 && frac < 0.999;
|
|
});
|
|
sectionStatus.rects = anyNoisy ? "pass" : "warn";
|
|
setDot("dot-rects", sectionStatus.rects);
|
|
}
|
|
|
|
// ── Plugins ──
|
|
function testPlugins() {
|
|
const el = document.getElementById("plugins-results");
|
|
const count = navigator.plugins.length;
|
|
const mimeCount = navigator.mimeTypes.length;
|
|
|
|
report.plugins = { pluginCount: count, mimeTypeCount: mimeCount };
|
|
el.innerHTML = row("navigator.plugins.length", count, count === 0 ? "spoofed" : "") +
|
|
row("navigator.mimeTypes.length", mimeCount, mimeCount === 0 ? "spoofed" : "");
|
|
|
|
sectionStatus.plugins = (count === 0 && mimeCount === 0) ? "pass" : "warn";
|
|
setDot("dot-plugins", sectionStatus.plugins);
|
|
}
|
|
|
|
// ── Battery ──
|
|
async function testBattery() {
|
|
const el = document.getElementById("battery-results");
|
|
if (!navigator.getBattery) {
|
|
report.battery = { status: "API not available" };
|
|
el.innerHTML = row("Status", "getBattery not available (good — hidden)", "spoofed");
|
|
sectionStatus.battery = "pass";
|
|
setDot("dot-battery", "pass");
|
|
return;
|
|
}
|
|
try {
|
|
await withTimeout(async () => {
|
|
const bat = await navigator.getBattery();
|
|
const vals = {
|
|
"charging": bat.charging,
|
|
"chargingTime": bat.chargingTime,
|
|
"dischargingTime": bat.dischargingTime,
|
|
"level": bat.level,
|
|
};
|
|
report.battery = vals;
|
|
|
|
let html = "";
|
|
for (const [k, v] of Object.entries(vals)) html += row(k, String(v));
|
|
el.innerHTML = html;
|
|
|
|
const spoofed = bat.charging === true && bat.level === 1.0 && bat.chargingTime === 0;
|
|
sectionStatus.battery = spoofed ? "pass" : "fail";
|
|
setDot("dot-battery", sectionStatus.battery);
|
|
}, 3000);
|
|
} catch (e) {
|
|
report.battery = { error: e.message };
|
|
el.innerHTML = row("Error", e.message);
|
|
sectionStatus.battery = "warn";
|
|
setDot("dot-battery", "warn");
|
|
}
|
|
}
|
|
|
|
// ── Connection ──
|
|
function testConnection() {
|
|
const el = document.getElementById("connection-results");
|
|
const conn = navigator.connection;
|
|
if (!conn) {
|
|
el.innerHTML = row("Status", "navigator.connection not available", "");
|
|
sectionStatus.connection = "warn";
|
|
setDot("dot-connection", "warn");
|
|
return;
|
|
}
|
|
const vals = {
|
|
"effectiveType": conn.effectiveType,
|
|
"downlink": conn.downlink,
|
|
"rtt": conn.rtt,
|
|
"saveData": conn.saveData,
|
|
};
|
|
report.connection = vals;
|
|
|
|
let html = "";
|
|
for (const [k, v] of Object.entries(vals)) html += row(k, String(v));
|
|
el.innerHTML = html;
|
|
|
|
const spoofed = conn.effectiveType === "4g" && conn.downlink === 10 && conn.rtt === 50;
|
|
sectionStatus.connection = spoofed ? "pass" : "warn";
|
|
setDot("dot-connection", sectionStatus.connection);
|
|
}
|
|
|
|
// ── WebRTC ──
|
|
async function testWebRTC() {
|
|
const el = document.getElementById("webrtc-results");
|
|
if (!window.RTCPeerConnection) {
|
|
report.webrtc = { status: "API blocked" };
|
|
el.innerHTML = row("Status", "RTCPeerConnection not available (good — blocked)", "spoofed");
|
|
sectionStatus.webrtc = "pass";
|
|
setDot("dot-webrtc", "pass");
|
|
return;
|
|
}
|
|
|
|
let pc;
|
|
try {
|
|
await withTimeout(async () => {
|
|
const candidates = [];
|
|
pc = new RTCPeerConnection({
|
|
iceServers: [{ urls: "stun:stun.l.google.com:19302" }]
|
|
});
|
|
|
|
pc.createDataChannel("");
|
|
const offer = await pc.createOffer();
|
|
await pc.setLocalDescription(offer);
|
|
|
|
await new Promise((resolve) => {
|
|
const timeout = setTimeout(resolve, 3000);
|
|
pc.onicecandidate = (e) => {
|
|
if (e.candidate) {
|
|
candidates.push(e.candidate.candidate);
|
|
} else {
|
|
clearTimeout(timeout);
|
|
resolve();
|
|
}
|
|
};
|
|
});
|
|
|
|
pc.close();
|
|
pc = null;
|
|
|
|
report.webrtc = { candidates };
|
|
|
|
if (candidates.length === 0) {
|
|
el.innerHTML = row("ICE candidates", "None gathered (relay-only mode active)", "spoofed");
|
|
sectionStatus.webrtc = "pass";
|
|
} else {
|
|
const localIPs = [];
|
|
const publicIPs = [];
|
|
const mdnsAddrs = [];
|
|
let hasHost = false, hasSrflx = false, hasRelay = false;
|
|
|
|
for (const c of candidates) {
|
|
if (/typ host/.test(c)) hasHost = true;
|
|
if (/typ srflx/.test(c)) hasSrflx = true;
|
|
if (/typ relay/.test(c)) hasRelay = true;
|
|
|
|
// Check for mDNS .local addresses (obfuscated local IPs)
|
|
const mdns = c.match(/([a-f0-9-]+\.local)/);
|
|
if (mdns) mdnsAddrs.push(mdns[1]);
|
|
|
|
const match = c.match(/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})/);
|
|
if (match) {
|
|
const ip = match[1];
|
|
if (/^(0\.0\.0\.0)$/.test(ip)) continue; // raddr placeholder
|
|
if (/^(10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|127\.)/.test(ip)) {
|
|
localIPs.push(ip);
|
|
} else {
|
|
publicIPs.push(ip);
|
|
}
|
|
}
|
|
// Check for IPv6 addresses
|
|
const ipv6 = c.match(/([0-9a-f]{4}:[0-9a-f:]+)/i);
|
|
if (ipv6) publicIPs.push(ipv6[1]);
|
|
}
|
|
|
|
let html = "";
|
|
if (publicIPs.length > 0) {
|
|
html += row("PUBLIC IP LEAKED", publicIPs.join(", "), "real");
|
|
}
|
|
if (localIPs.length > 0) {
|
|
html += row("LOCAL IP LEAKED", localIPs.join(", "), "real");
|
|
}
|
|
if (mdnsAddrs.length > 0) {
|
|
html += row("mDNS addresses", mdnsAddrs.join(", "));
|
|
}
|
|
html += row("Candidate types",
|
|
(hasHost ? "host " : "") + (hasSrflx ? "srflx " : "") + (hasRelay ? "relay " : ""),
|
|
hasHost || hasSrflx ? "real" : "spoofed");
|
|
html += row("Total candidates", candidates.length);
|
|
for (const c of candidates) {
|
|
html += row("", c);
|
|
}
|
|
el.innerHTML = html;
|
|
|
|
// FAIL if any non-relay candidates or any public IPs leaked
|
|
sectionStatus.webrtc = (publicIPs.length > 0 || localIPs.length > 0 || hasHost || hasSrflx) ? "fail" : "pass";
|
|
}
|
|
setDot("dot-webrtc", sectionStatus.webrtc);
|
|
}, 5000);
|
|
} catch (e) {
|
|
if (pc) try { pc.close(); } catch(_) {}
|
|
report.webrtc = { error: e.message };
|
|
el.innerHTML = row("Status", e.message);
|
|
sectionStatus.webrtc = "warn";
|
|
setDot("dot-webrtc", "warn");
|
|
}
|
|
}
|
|
|
|
// ── HTTP Headers ──
|
|
async function testHeaders() {
|
|
const el = document.getElementById("headers-results");
|
|
try {
|
|
// Fetch a resource and check what headers the server sees
|
|
// We use a self-request to inspect our own headers via the server
|
|
// Since we can't see outgoing headers directly, we check for coherence:
|
|
// The JS navigator.userAgent should match what's sent in HTTP
|
|
const jsUA = navigator.userAgent;
|
|
const jsLang = navigator.languages ? navigator.languages.join(",") : navigator.language;
|
|
|
|
// We can detect the HTTP Accept-Language by examining what the browser sends
|
|
// For now, just report the JS values and note that HTTP header spoofing
|
|
// requires checking server-side (e.g., httpbin.org/headers)
|
|
const vals = {
|
|
"JS navigator.userAgent": jsUA,
|
|
"JS navigator.languages": jsLang,
|
|
"HTTP header check": "Visit httpbin.org/headers to verify match"
|
|
};
|
|
report.headers = vals;
|
|
|
|
let html = row("JS navigator.userAgent", jsUA);
|
|
html += row("JS navigator.languages", jsLang);
|
|
|
|
// Try fetching our own page to see if headers are modified
|
|
try {
|
|
const resp = await fetch(window.location.href, { method: "HEAD" });
|
|
html += row("Fetch completed", "Headers sent with spoofed values (verify server-side)", "spoofed");
|
|
} catch(e) {
|
|
html += row("Fetch test", "Could not verify: " + e.message);
|
|
}
|
|
|
|
el.innerHTML = html;
|
|
|
|
// If the JS UA doesn't match the real browser, headers are likely spoofed too
|
|
const isSpoofed = !/rv:148\.0/.test(jsUA);
|
|
sectionStatus.headers = isSpoofed ? "pass" : "fail";
|
|
setDot("dot-headers", sectionStatus.headers);
|
|
} catch(e) {
|
|
report.headers = { error: e.message };
|
|
el.innerHTML = row("Error", e.message);
|
|
sectionStatus.headers = "warn";
|
|
setDot("dot-headers", "warn");
|
|
}
|
|
}
|
|
|
|
// ── Speech Synthesis ──
|
|
function testSpeech() {
|
|
const el = document.getElementById("speech-results");
|
|
if (!window.speechSynthesis) {
|
|
report.speech = { status: "API not available" };
|
|
el.innerHTML = row("Status", "speechSynthesis not available", "");
|
|
sectionStatus.speech = "warn";
|
|
setDot("dot-speech", "warn");
|
|
return;
|
|
}
|
|
|
|
const voices = speechSynthesis.getVoices();
|
|
const count = voices.length;
|
|
report.speech = { voiceCount: count };
|
|
el.innerHTML = row("speechSynthesis.getVoices().length", count, count === 0 ? "spoofed" : "real");
|
|
sectionStatus.speech = count === 0 ? "pass" : "fail";
|
|
setDot("dot-speech", sectionStatus.speech);
|
|
}
|
|
|
|
// ── matchMedia ──
|
|
function testMatchMedia() {
|
|
const el = document.getElementById("matchmedia-results");
|
|
const screenW = screen.width;
|
|
const screenH = screen.height;
|
|
|
|
// Test if matchMedia agrees with our spoofed screen dimensions
|
|
const wideEnough = matchMedia(`(min-width: ${screenW}px)`).matches;
|
|
const notTooWide = matchMedia(`(max-width: ${screenW}px)`).matches;
|
|
const tallEnough = matchMedia(`(min-height: ${screenH}px)`).matches;
|
|
|
|
// Test against real dimensions — should NOT match if spoofed
|
|
const realWCheck = matchMedia("(min-width: 1920px)").matches;
|
|
const realHCheck = matchMedia("(min-height: 1080px)").matches;
|
|
|
|
const vals = {
|
|
[`(min-width: ${screenW}px)`]: wideEnough,
|
|
[`(max-width: ${screenW}px)`]: notTooWide,
|
|
[`(min-height: ${screenH}px)`]: tallEnough,
|
|
"(min-width: 1920px) [real]": realWCheck,
|
|
"(min-height: 1080px) [real]": realHCheck,
|
|
};
|
|
report.matchmedia = vals;
|
|
|
|
let html = "";
|
|
html += row(`min-width: ${screenW}px`, wideEnough ? "matches" : "no match",
|
|
wideEnough ? "spoofed" : "real");
|
|
html += row(`max-width: ${screenW}px`, notTooWide ? "matches" : "no match");
|
|
html += row(`min-height: ${screenH}px`, tallEnough ? "matches" : "no match",
|
|
tallEnough ? "spoofed" : "real");
|
|
html += row("min-width: 1920px [real check]", realWCheck ? "matches (real leak)" : "no match (good)",
|
|
realWCheck && screenW !== 1920 ? "real" : "spoofed");
|
|
el.innerHTML = html;
|
|
|
|
// Pass if spoofed dimensions are consistent with matchMedia
|
|
sectionStatus.matchmedia = wideEnough ? "pass" : "warn";
|
|
setDot("dot-matchmedia", sectionStatus.matchmedia);
|
|
}
|
|
|
|
// ── Performance Timing ──
|
|
function testPerf() {
|
|
const el = document.getElementById("perf-results");
|
|
// Take multiple samples and check precision
|
|
const samples = [];
|
|
for (let i = 0; i < 20; i++) samples.push(performance.now());
|
|
|
|
// Check the decimal precision of the samples
|
|
const precisions = samples.map(s => {
|
|
const str = s.toString();
|
|
const dot = str.indexOf(".");
|
|
return dot === -1 ? 0 : str.length - dot - 1;
|
|
});
|
|
const maxPrecision = Math.max(...precisions);
|
|
|
|
// Check if values are rounded (reduced precision)
|
|
const allRounded = samples.every(s => {
|
|
const frac = s % 0.1;
|
|
return Math.abs(frac) < 0.001 || Math.abs(frac - 0.1) < 0.001;
|
|
});
|
|
|
|
report.perf = {
|
|
sampleCount: samples.length,
|
|
maxDecimalPrecision: maxPrecision,
|
|
appearsRounded: allRounded,
|
|
sampleRange: `${samples[0].toFixed(3)} - ${samples[samples.length-1].toFixed(3)}`
|
|
};
|
|
|
|
let html = row("Max decimal precision", maxPrecision + " digits");
|
|
html += row("Values rounded to 0.1ms", allRounded ? "yes" : "no", allRounded ? "spoofed" : "");
|
|
html += row("Sample range", `${samples[0].toFixed(3)} - ${samples[samples.length-1].toFixed(3)}`);
|
|
el.innerHTML = html;
|
|
|
|
sectionStatus.perf = allRounded ? "pass" : "warn";
|
|
setDot("dot-perf", sectionStatus.perf);
|
|
}
|
|
|
|
// ── Storage Estimate ──
|
|
async function testStorage() {
|
|
const el = document.getElementById("storage-results");
|
|
if (!navigator.storage || !navigator.storage.estimate) {
|
|
report.storage = { status: "API not available" };
|
|
el.innerHTML = row("Status", "storage.estimate not available", "");
|
|
sectionStatus.storage = "warn";
|
|
setDot("dot-storage", "warn");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await withTimeout(async () => {
|
|
const est = await navigator.storage.estimate();
|
|
report.storage = { quota: est.quota, usage: est.usage };
|
|
|
|
const isSpoofed = est.quota === 2147483648 && est.usage === 0;
|
|
el.innerHTML = row("quota", est.quota.toLocaleString() + " bytes", isSpoofed ? "spoofed" : "") +
|
|
row("usage", est.usage.toLocaleString() + " bytes", isSpoofed ? "spoofed" : "");
|
|
sectionStatus.storage = isSpoofed ? "pass" : "warn";
|
|
setDot("dot-storage", sectionStatus.storage);
|
|
}, 3000);
|
|
} catch(e) {
|
|
report.storage = { error: e.message };
|
|
el.innerHTML = row("Error", e.message);
|
|
sectionStatus.storage = "warn";
|
|
setDot("dot-storage", "warn");
|
|
}
|
|
}
|
|
|
|
// ── Font Enumeration (DOM) ──
|
|
function testFontDOM() {
|
|
const el = document.getElementById("fontdom-results");
|
|
const probe = document.getElementById("fontdom-probe");
|
|
|
|
// Measure element dimensions — should have noise if spoofed
|
|
const w = probe.offsetWidth;
|
|
const h = probe.offsetHeight;
|
|
const sw = probe.scrollWidth;
|
|
const sh = probe.scrollHeight;
|
|
const cw = probe.clientWidth;
|
|
const ch = probe.clientHeight;
|
|
|
|
const vals = {
|
|
"offsetWidth": w, "offsetHeight": h,
|
|
"scrollWidth": sw, "scrollHeight": sh,
|
|
"clientWidth": cw, "clientHeight": ch
|
|
};
|
|
report.fontdom = vals;
|
|
|
|
let html = "";
|
|
for (const [k, v] of Object.entries(vals)) {
|
|
const hasFrac = v % 1 !== 0;
|
|
html += row(k, v.toFixed(6), hasFrac ? "spoofed" : "");
|
|
}
|
|
el.innerHTML = html;
|
|
|
|
// If noise is applied, at least some values will have fractional parts
|
|
const anyFrac = Object.values(vals).some(v => v % 1 !== 0);
|
|
sectionStatus.fontdom = anyFrac ? "pass" : "warn";
|
|
setDot("dot-fontdom", sectionStatus.fontdom);
|
|
}
|
|
|
|
// ── document.fonts API ──
|
|
function testDocFonts() {
|
|
const el = document.getElementById("docfonts-results");
|
|
if (!document.fonts) {
|
|
report.docfonts = { status: "API not available" };
|
|
el.innerHTML = row("Status", "document.fonts not available", "");
|
|
sectionStatus.docfonts = "warn";
|
|
setDot("dot-docfonts", "warn");
|
|
return;
|
|
}
|
|
|
|
const size = document.fonts.size;
|
|
const checkArial = document.fonts.check("16px Arial");
|
|
const checkMono = document.fonts.check("16px monospace");
|
|
|
|
const vals = {
|
|
"document.fonts.size": size,
|
|
"check('16px Arial')": checkArial,
|
|
"check('16px monospace')": checkMono,
|
|
};
|
|
report.docfonts = vals;
|
|
|
|
let html = "";
|
|
html += row("document.fonts.size", size);
|
|
html += row("check('16px Arial')", checkArial, checkArial ? "spoofed" : "real");
|
|
html += row("check('16px monospace')", checkMono, checkMono ? "spoofed" : "real");
|
|
el.innerHTML = html;
|
|
|
|
// check() returning true for everything = spoofed (uniform response)
|
|
const spoofed = checkArial && checkMono;
|
|
sectionStatus.docfonts = spoofed ? "pass" : "warn";
|
|
setDot("dot-docfonts", sectionStatus.docfonts);
|
|
}
|
|
|
|
// ── Client Hints Headers ──
|
|
async function testClientHints() {
|
|
const el = document.getElementById("clienthints-results");
|
|
try {
|
|
await withTimeout(async () => {
|
|
// Try fetching httpbin.org to see what headers are actually sent
|
|
let html = "";
|
|
try {
|
|
const resp = await fetch("https://httpbin.org/headers", {
|
|
method: "GET",
|
|
mode: "cors"
|
|
});
|
|
const data = await resp.json();
|
|
const headers = data.headers || {};
|
|
|
|
const ua = headers["User-Agent"] || "(not sent)";
|
|
const al = headers["Accept-Language"] || "(not sent)";
|
|
const chUA = headers["Sec-Ch-Ua"] || "(not sent)";
|
|
const chPlatform = headers["Sec-Ch-Ua-Platform"] || "(not sent)";
|
|
const chMobile = headers["Sec-Ch-Ua-Mobile"] || "(not sent)";
|
|
|
|
report.clienthints = {
|
|
"HTTP User-Agent": ua,
|
|
"HTTP Accept-Language": al,
|
|
"Sec-CH-UA": chUA,
|
|
"Sec-CH-UA-Platform": chPlatform,
|
|
"Sec-CH-UA-Mobile": chMobile
|
|
};
|
|
|
|
html += row("HTTP User-Agent", ua);
|
|
html += row("HTTP Accept-Language", al);
|
|
html += row("Sec-CH-UA", chUA, chUA === "(not sent)" ? "spoofed" : "");
|
|
html += row("Sec-CH-UA-Platform", chPlatform, chPlatform === "(not sent)" ? "spoofed" : "");
|
|
html += row("Sec-CH-UA-Mobile", chMobile, chMobile === "(not sent)" ? "spoofed" : "");
|
|
|
|
// Check if HTTP UA matches JS UA
|
|
const jsUA = navigator.userAgent;
|
|
const uaMatch = ua.includes("Firefox") && jsUA.includes("Firefox");
|
|
html += row("JS/HTTP UA coherent", uaMatch ? "yes" : "MISMATCH", uaMatch ? "spoofed" : "real");
|
|
|
|
sectionStatus.clienthints = uaMatch ? "pass" : "fail";
|
|
} catch(e) {
|
|
report.clienthints = { note: "httpbin.org fetch failed (CORS/network)" };
|
|
html += row("httpbin.org fetch", "Failed: " + e.message);
|
|
html += row("Note", "Client Hints are stripped by the extension if present");
|
|
sectionStatus.clienthints = "warn";
|
|
}
|
|
|
|
el.innerHTML = html;
|
|
setDot("dot-clienthints", sectionStatus.clienthints);
|
|
}, 5000);
|
|
} catch(e) {
|
|
report.clienthints = { error: e.message };
|
|
el.innerHTML = row("Error", e.message);
|
|
sectionStatus.clienthints = "warn";
|
|
setDot("dot-clienthints", "warn");
|
|
}
|
|
}
|
|
|
|
// ── Gamepad API ──
|
|
function testGamepad() {
|
|
const el = document.getElementById("gamepad-results");
|
|
if (!navigator.getGamepads) {
|
|
report.gamepad = { status: "API not available" };
|
|
el.innerHTML = row("Status", "navigator.getGamepads not available", "");
|
|
sectionStatus.gamepad = "warn";
|
|
setDot("dot-gamepad", "warn");
|
|
return;
|
|
}
|
|
|
|
const gamepads = navigator.getGamepads();
|
|
const count = Array.from(gamepads).filter(g => g !== null).length;
|
|
report.gamepad = { totalSlots: gamepads.length, connectedCount: count };
|
|
el.innerHTML = row("Gamepad slots", gamepads.length) +
|
|
row("Connected gamepads", count, count === 0 ? "spoofed" : "real");
|
|
sectionStatus.gamepad = count === 0 ? "pass" : "warn";
|
|
setDot("dot-gamepad", sectionStatus.gamepad);
|
|
}
|
|
|
|
// ── WebGL readPixels ──
|
|
async function testReadPixels() {
|
|
const el = document.getElementById("readpixels-results");
|
|
const c = document.createElement("canvas");
|
|
c.width = 64;
|
|
c.height = 64;
|
|
const gl = c.getContext("webgl");
|
|
if (!gl) {
|
|
report.readpixels = { status: "WebGL not available" };
|
|
el.innerHTML = row("Status", "WebGL not available", "");
|
|
sectionStatus.readpixels = "warn";
|
|
setDot("dot-readpixels", "warn");
|
|
return;
|
|
}
|
|
|
|
// Draw something
|
|
gl.clearColor(0.5, 0.3, 0.7, 1.0);
|
|
gl.clear(gl.COLOR_BUFFER_BIT);
|
|
|
|
const pixels = new Uint8Array(64 * 64 * 4);
|
|
gl.readPixels(0, 0, 64, 64, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
|
|
|
|
// Hash the pixel data
|
|
let sum = 0;
|
|
for (let i = 0; i < pixels.length; i++) sum += pixels[i];
|
|
const hash = await sha256Short(sum.toString());
|
|
|
|
report.readpixels = { pixelSum: sum, hash };
|
|
el.innerHTML = row("readPixels hash", hash) +
|
|
row("Pixel sum", sum);
|
|
sectionStatus.readpixels = "pass";
|
|
setDot("dot-readpixels", "pass");
|
|
}
|
|
|
|
// ── Summary ──
|
|
function updateSummary() {
|
|
let pass = 0, fail = 0, warn = 0;
|
|
for (const s of Object.values(sectionStatus)) {
|
|
if (s === "pass") pass++;
|
|
else if (s === "fail") fail++;
|
|
else warn++;
|
|
}
|
|
document.getElementById("sum-pass-n").textContent = pass;
|
|
document.getElementById("sum-fail-n").textContent = fail;
|
|
document.getElementById("sum-warn-n").textContent = warn;
|
|
document.getElementById("sum-pass").className = "status-dot " + (pass > 0 ? "pass" : "pending");
|
|
document.getElementById("sum-fail").className = "status-dot " + (fail > 0 ? "fail" : "pending");
|
|
document.getElementById("sum-warn").className = "status-dot " + (warn > 0 ? "warn" : "pending");
|
|
}
|
|
|
|
// ── Composite hash ──
|
|
async function computeComposite() {
|
|
const composite = JSON.stringify(report);
|
|
const hash = await sha256Short(composite);
|
|
document.getElementById("composite-hash").textContent = hash;
|
|
}
|
|
|
|
// ── Build report text ──
|
|
function buildReportText() {
|
|
const lines = [`ContainSite Fingerprint Report — ${new Date().toISOString()}`, ""];
|
|
|
|
for (const [section, data] of Object.entries(report)) {
|
|
const status = sectionStatus[section] || "?";
|
|
lines.push(`[${status.toUpperCase()}] ${section}`);
|
|
if (typeof data === "object") {
|
|
for (const [k, v] of Object.entries(data)) {
|
|
lines.push(` ${k}: ${v}`);
|
|
}
|
|
}
|
|
lines.push("");
|
|
}
|
|
|
|
lines.push("Composite hash: " + document.getElementById("composite-hash").textContent);
|
|
return lines.join("\n");
|
|
}
|
|
|
|
// ── Copy report (textarea fallback for HTTP) ──
|
|
function copyReport() {
|
|
const text = buildReportText();
|
|
const ta = document.createElement("textarea");
|
|
ta.value = text;
|
|
ta.style.position = "fixed";
|
|
ta.style.left = "-9999px";
|
|
document.body.appendChild(ta);
|
|
ta.select();
|
|
try {
|
|
document.execCommand("copy");
|
|
const btn = document.querySelector(".copy-btn");
|
|
btn.textContent = "Copied!";
|
|
setTimeout(() => btn.textContent = "Copy Full Report to Clipboard", 2000);
|
|
} catch(e) {
|
|
// If copy also fails, show the report in a prompt
|
|
prompt("Copy this report:", text);
|
|
}
|
|
document.body.removeChild(ta);
|
|
}
|
|
|
|
// ── Run all ──
|
|
async function runAll() {
|
|
// Sync tests first
|
|
try { testNavigator(); } catch(e) { console.error("navigator test:", e); }
|
|
try { testScreen(); } catch(e) { console.error("screen test:", e); }
|
|
try { testWebGL(); } catch(e) { console.error("webgl test:", e); }
|
|
try { testTimezone(); } catch(e) { console.error("timezone test:", e); }
|
|
try { testFonts(); } catch(e) { console.error("fonts test:", e); }
|
|
try { testRects(); } catch(e) { console.error("rects test:", e); }
|
|
try { testPlugins(); } catch(e) { console.error("plugins test:", e); }
|
|
try { testConnection(); } catch(e) { console.error("connection test:", e); }
|
|
try { testSpeech(); } catch(e) { console.error("speech test:", e); }
|
|
try { testMatchMedia(); } catch(e) { console.error("matchmedia test:", e); }
|
|
try { testPerf(); } catch(e) { console.error("perf test:", e); }
|
|
try { testFontDOM(); } catch(e) { console.error("fontdom test:", e); }
|
|
try { testDocFonts(); } catch(e) { console.error("docfonts test:", e); }
|
|
try { testGamepad(); } catch(e) { console.error("gamepad test:", e); }
|
|
|
|
// Async tests — run in parallel with individual error handling
|
|
await Promise.allSettled([
|
|
testCanvas(),
|
|
testAudio(),
|
|
testBattery(),
|
|
testWebRTC(),
|
|
testHeaders(),
|
|
testStorage(),
|
|
testClientHints(),
|
|
testReadPixels(),
|
|
]);
|
|
|
|
updateSummary();
|
|
await computeComposite();
|
|
}
|
|
|
|
runAll();
|
|
</script>
|
|
</body>
|
|
</html>
|