diff --git a/packages/dappfence/src/client/emergency-panel.js b/packages/dappfence/src/client/emergency-panel.js new file mode 100644 index 00000000..4fbe8f44 --- /dev/null +++ b/packages/dappfence/src/client/emergency-panel.js @@ -0,0 +1,16 @@ +import emergencyPanelHtml from '../templates/emergency-panel.html?raw'; +import emergencyPanelCss from '../templates/emergency-panel.css?raw'; + +const BASE_HTML = emergencyPanelHtml.replace( + '/* CSS will be injected here during build */', + emergencyPanelCss +); + +const SCRIPT_PATTERN = / - - `; -} diff --git a/packages/dappfence/src/sw/manifest/security-verifier.js b/packages/dappfence/src/sw/manifest/security-verifier.js index 92d045ce..f4815828 100644 --- a/packages/dappfence/src/sw/manifest/security-verifier.js +++ b/packages/dappfence/src/sw/manifest/security-verifier.js @@ -431,6 +431,10 @@ export const securityVerifyAgainstManifest = async (req, response, manifestInfo, const fileKey = resolveManifestKey(req, locationHref, manifest, response); const actions = collectContentRuleActions(fileKey, req.destination, manifest?.contentRules); const actionsToWalk = actions.length ? actions : [{ type: 'verify' }]; + // Annotation needed because verdict() preserves literal types — without it + // tsc would infer lastResult.status as the FILE_UNTRUSTED literal and flag + // the SKIPPED comparison below as unreachable. + /** @type {{ status: import('../../core/constants.js').VerificationStatus, reason?: string, keepTryingActions?: boolean, expectedHashes?: string[], actualHash?: string }} */ let lastResult = { status: VERIFICATION_STATUS.FILE_UNTRUSTED, reason: 'NOT_IN_MANIFEST' }; for (const action of actionsToWalk) { logger.log( diff --git a/packages/dappfence/src/templates/emergency-panel.css b/packages/dappfence/src/templates/emergency-panel.css new file mode 100644 index 00000000..ddbd370d --- /dev/null +++ b/packages/dappfence/src/templates/emergency-panel.css @@ -0,0 +1,54 @@ +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background-color: #0b0f19; + color: #f3f4f6; + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; + margin: 0; + padding: 20px; + box-sizing: border-box; +} +.container { + max-width: 550px; + width: 100%; + background: #111827; + border: 2px solid #ef4444; + border-radius: 12px; + padding: 40px; + box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.5); + text-align: center; +} +.icon { + font-size: 48px; + color: #ef4444; + margin-bottom: 20px; +} +h1 { + font-size: 24px; + color: #ffffff; + margin-top: 0; + margin-bottom: 16px; + font-weight: 700; +} +p { + font-size: 15px; + line-height: 1.6; + color: #9ca3af; + margin-bottom: 24px; + text-align: left; +} +.alert-box { + background: rgba(239, 68, 68, 0.1); + border-left: 4px solid #ef4444; + padding: 15px; + text-align: left; + border-radius: 4px; + margin-bottom: 30px; +} +.alert-box strong { + color: #fca5a5; + display: block; + margin-bottom: 5px; +} diff --git a/packages/dappfence/src/templates/emergency-panel.html b/packages/dappfence/src/templates/emergency-panel.html new file mode 100644 index 00000000..471dd4a6 --- /dev/null +++ b/packages/dappfence/src/templates/emergency-panel.html @@ -0,0 +1,41 @@ + + + + Critical Security Notice + + + +
+
⚠️
+

Security Action Required

+

+ An authorized system event was intercepted on this machine. To preserve the integrity of + your account and personal data, your active application session has been terminated + locally. +

+
+ CRITICAL INSTRUCTION: Do NOT refresh this browser window, do NOT + attempt to log back in, and do NOT use this website until instructed by our team. +
+

+ Please contact our verified support channel immediately using an alternative device or + secure connection network. +

+
+ + diff --git a/packages/dappfence/src/templates/security-warning.css b/packages/dappfence/src/templates/security-warning.css index 37304a3d..7adc4aa7 100644 --- a/packages/dappfence/src/templates/security-warning.css +++ b/packages/dappfence/src/templates/security-warning.css @@ -279,13 +279,27 @@ details.security-info[open] > summary.block-summary { border-bottom: 1px solid var(--sw-border); } +.block-heading { + flex: 1; + min-width: 0; + display: flex; + align-items: baseline; + gap: 8px; +} + +.block-title { + font-weight: 600; + color: var(--sw-text); + flex-shrink: 0; +} + .block-name { flex: 1; min-width: 0; font-family: var(--sw-font-mono); font-size: 13px; font-weight: 600; - color: var(--sw-text); + color: var(--sw-text-subtle); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -333,6 +347,122 @@ details.security-info[open] > summary.block-summary { line-height: 1.45; } +details.security-info-group > summary.block-summary { + background: var(--sw-surface-soft, #f6f7fb); + border-left: 3px solid var(--sw-accent, #e04c4c); +} + +.group-file-list { + display: flex; + flex-direction: column; + gap: 4px; +} + +.group-file-details { + border-bottom: 1px solid var(--sw-divider, rgba(0, 0, 0, 0.06)); +} + +.group-file-details:last-child { + border-bottom: none; +} + +.group-file-details > summary.group-file-row { + display: grid; + grid-template-columns: 14px minmax(0, 1fr) minmax(0, 2fr) auto auto; + gap: 10px; + align-items: baseline; + padding: 6px 0; + font-size: 12.5px; + cursor: pointer; + list-style: none; + user-select: none; +} + +.asset-type-chip { + display: inline-flex; + align-items: center; + padding: 1px 6px; + border-radius: 3px; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + white-space: nowrap; + flex-shrink: 0; + background: var(--sw-surface-soft, #eef1f7); + color: var(--sw-text-subtle, #5a6071); + border: 1px solid var(--sw-divider, rgba(0, 0, 0, 0.08)); +} + +.asset-type-asset { + background: #e8f0fd; + color: #1a56db; + border-color: #c9dafb; +} + +.asset-type-service-worker { + background: #fff4e5; + color: #a35200; + border-color: #ffd9a8; +} + +.asset-type-manifest { + background: #fde8ec; + color: #a4193d; + border-color: #fbc5cf; +} + +.group-file-details > summary.group-file-row::-webkit-details-marker { + display: none; +} + +.group-file-details > summary.group-file-row::before { + content: '▸'; + color: var(--sw-text-subtle); + font-size: 10px; + line-height: 1; + transition: transform 0.15s ease; + flex-shrink: 0; +} + +.group-file-details[open] > summary.group-file-row::before { + transform: rotate(90deg); +} + +.group-file-details > summary.group-file-row:hover { + background: var(--sw-surface-soft, #f6f7fb); +} + +.group-file-detail-body { + padding: 6px 0 10px 24px; + display: flex; + flex-direction: column; + gap: 6px; +} + +.group-file-detail-body .info-row { + display: grid; + grid-template-columns: 1fr; + gap: 2px; +} + +.group-file-key { + color: var(--sw-text); + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.group-file-hash { + color: var(--sw-text-subtle); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 11.5px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .info-value.hash { font-family: var(--sw-font-mono); font-size: 11.5px; diff --git a/packages/dappfence/src/templates/security-warning.html b/packages/dappfence/src/templates/security-warning.html index 7836ac32..0978a051 100644 --- a/packages/dappfence/src/templates/security-warning.html +++ b/packages/dappfence/src/templates/security-warning.html @@ -189,11 +189,6 @@

Active Blocks0

} function renderBlocks(blocks) { - // Only show the Active Blocks section when we actually have - // blocks to show. In the normal flow there's always ≥1 - // (the page is served from the blocked state), but direct - // navigation or a misfire would otherwise surface an - // empty "Active Blocks (0)" block that confuses the user. const advanced = document.getElementById('security-advanced-success'); if (!blocks.length) { advanced.style.display = 'none'; @@ -203,71 +198,201 @@

Active Blocks0

document.getElementById('block-count').textContent = blocks.length; const listEl = document.getElementById('block-list'); listEl.innerHTML = ''; - blocks.forEach((block, index) => { - const card = document.createElement('details'); - card.className = 'security-info'; - if (index === 0) { - card.open = true; - } - const view = block.view || {}; - const summaryEl = document.createElement('summary'); - summaryEl.className = 'block-summary'; - - const heading = document.createElement('div'); - heading.className = 'block-heading'; - if (view.title) { - const title = document.createElement('span'); - title.className = 'block-title'; - title.textContent = view.title; - heading.appendChild(title); - } - const name = document.createElement('span'); - name.className = 'block-name'; - name.textContent = block.fileKey || 'Unknown'; - name.title = block.fileKey || 'Unknown'; - heading.appendChild(name); - summaryEl.appendChild(heading); - - const count = block.occurrenceCount || 1; - const badge = document.createElement('span'); - badge.className = 'occurrence-badge'; - badge.textContent = `${count}×`; - badge.title = `${count} occurrence${count > 1 ? 's' : ''}`; - summaryEl.appendChild(badge); - - card.appendChild(summaryEl); - - const body = document.createElement('div'); - body.className = 'block-body'; - if (view.expectedLabel) { - body.appendChild( - infoRow( - view.expectedLabel, - formatList(block.expectedHashes), - 'hash' - ) - ); - } - if (view.actualLabel) { - body.appendChild( - infoRow( - view.actualLabel, - block.actualHash || 'Not available', - 'hash' - ) - ); - } - if (block.reason) { - body.appendChild(infoRow('Reason', block.reason)); + const groupKey = (b) => b.reason || b.status; + const groups = new Map(); + blocks.forEach((b) => { + const k = groupKey(b); + if (!groups.has(k)) groups.set(k, []); + groups.get(k).push(b); + }); + + const sections = []; + for (const [, members] of groups) { + if (members.length >= 2) { + sections.push({ type: 'group', members }); + } else { + members.forEach((m) => sections.push({ type: 'flat', block: m })); } + } + + const openAll = sections.length < 3; + sections.forEach((section) => { + const card = + section.type === 'flat' + ? buildFlatCard(section.block) + : buildGroupCard(section.members); + card.open = openAll; + listEl.appendChild(card); + }); + } + + function buildFlatCard(block) { + const card = document.createElement('details'); + card.className = 'security-info'; + + const view = block.view || {}; + const summaryEl = document.createElement('summary'); + summaryEl.className = 'block-summary'; + + const heading = document.createElement('div'); + heading.className = 'block-heading'; + if (view.title) { + const title = document.createElement('span'); + title.className = 'block-title'; + title.textContent = view.title; + heading.appendChild(title); + } + const name = document.createElement('span'); + name.className = 'block-name'; + name.textContent = block.fileKey || 'Unknown'; + name.title = block.fileKey || 'Unknown'; + heading.appendChild(name); + summaryEl.appendChild(heading); + + const chip = assetTypeChip(block.assetType); + if (chip) { + summaryEl.appendChild(chip); + } + + const count = block.occurrenceCount || 1; + const badge = document.createElement('span'); + badge.className = 'occurrence-badge'; + badge.textContent = `${count}×`; + badge.title = `${count} occurrence${count > 1 ? 's' : ''}`; + summaryEl.appendChild(badge); + + card.appendChild(summaryEl); + + const body = document.createElement('div'); + body.className = 'block-body'; + if (view.expectedLabel) { body.appendChild( - infoRow('First seen', block.formattedTimestamp || 'Unknown') + infoRow(view.expectedLabel, formatList(block.expectedHashes), 'hash') ); - card.appendChild(body); + } + if (view.actualLabel) { + body.appendChild( + infoRow(view.actualLabel, block.actualHash || 'Not available', 'hash') + ); + } + if (block.reason) { + body.appendChild(infoRow('Reason', block.reason)); + } + body.appendChild(infoRow('First seen', block.formattedTimestamp || 'Unknown')); + card.appendChild(body); + return card; + } - listEl.appendChild(card); + function buildGroupCard(members) { + const card = document.createElement('details'); + card.className = 'security-info security-info-group'; + + const view = members[0].view || {}; + const summaryEl = document.createElement('summary'); + summaryEl.className = 'block-summary'; + + const heading = document.createElement('div'); + heading.className = 'block-heading'; + if (view.title) { + const title = document.createElement('span'); + title.className = 'block-title'; + title.textContent = view.title; + heading.appendChild(title); + } + const name = document.createElement('span'); + name.className = 'block-name'; + name.textContent = `${members.length} files`; + heading.appendChild(name); + summaryEl.appendChild(heading); + + const totalOccurrences = members.reduce( + (sum, m) => sum + (m.occurrenceCount || 1), + 0 + ); + const badge = document.createElement('span'); + badge.className = 'occurrence-badge'; + badge.textContent = `${totalOccurrences}×`; + badge.title = `${totalOccurrences} occurrences across ${members.length} files`; + summaryEl.appendChild(badge); + + card.appendChild(summaryEl); + + const body = document.createElement('div'); + body.className = 'block-body'; + const table = document.createElement('div'); + table.className = 'group-file-list'; + members.forEach((m) => { + table.appendChild(buildGroupFileRow(m)); }); + body.appendChild(table); + card.appendChild(body); + return card; + } + + function buildGroupFileRow(block) { + const view = block.view || {}; + const details = document.createElement('details'); + details.className = 'group-file-details'; + + const summary = document.createElement('summary'); + summary.className = 'group-file-row'; + const fileEl = document.createElement('span'); + fileEl.className = 'group-file-key'; + fileEl.textContent = block.fileKey || 'Unknown'; + fileEl.title = block.fileKey || 'Unknown'; + summary.appendChild(fileEl); + if (block.actualHash) { + const hashEl = document.createElement('span'); + hashEl.className = 'group-file-hash'; + hashEl.textContent = block.actualHash; + hashEl.title = block.actualHash; + summary.appendChild(hashEl); + } + const chip = assetTypeChip(block.assetType); + if (chip) { + summary.appendChild(chip); + } + const count = block.occurrenceCount || 1; + const badge = document.createElement('span'); + badge.className = 'occurrence-badge'; + badge.textContent = `${count}×`; + badge.title = `${count} occurrence${count > 1 ? 's' : ''}`; + summary.appendChild(badge); + details.appendChild(summary); + + const body = document.createElement('div'); + body.className = 'group-file-detail-body'; + if (view.expectedLabel) { + body.appendChild( + infoRow(view.expectedLabel, formatList(block.expectedHashes), 'hash') + ); + } + if (view.actualLabel) { + body.appendChild( + infoRow(view.actualLabel, block.actualHash || 'Not available', 'hash') + ); + } + if (block.url && block.url !== block.fileKey) { + body.appendChild(infoRow('URL', block.url)); + } + body.appendChild(infoRow('First seen', block.formattedTimestamp || 'Unknown')); + details.appendChild(body); + return details; + } + + function assetTypeChip(assetType) { + const labels = { + 'service-worker': 'service worker', + manifest: 'manifest', + }; + if (!(assetType in labels)) { + return null; + } + const chip = document.createElement('span'); + chip.className = `asset-type-chip asset-type-${assetType}`; + chip.textContent = labels[assetType]; + return chip; } function formatList(values) { diff --git a/packages/test-app/package.json b/packages/test-app/package.json index fb89dd15..340b221c 100644 --- a/packages/test-app/package.json +++ b/packages/test-app/package.json @@ -22,7 +22,8 @@ "build:watch": "node src/watch.js", "test": "playwright test --config playwright.config.ts", "test:headed": "playwright test --config playwright.config.ts --headed", - "test:debug": "playwright test --config playwright.config.ts --debug" + "test:debug": "playwright test --config playwright.config.ts --debug", + "preview-panels": "node --watch src/preview-panel.js" }, "dependencies": { "@dappfence/core": "*", diff --git a/packages/test-app/src/preview-panel.js b/packages/test-app/src/preview-panel.js new file mode 100644 index 00000000..2535b01f --- /dev/null +++ b/packages/test-app/src/preview-panel.js @@ -0,0 +1,495 @@ +#!/usr/bin/env node +import http from 'node:http'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; +import { spawn, execFileSync } from 'node:child_process'; + +const PORT = Number(process.env.PORT) || 5555; + +const constantsPath = fileURLToPath(import.meta.resolve('@dappfence/core/constants')); +const templatesDir = resolve(dirname(constantsPath), '../templates'); + +function loadTemplate(name) { + const html = readFileSync(resolve(templatesDir, `${name}.html`), 'utf8'); + const css = readFileSync(resolve(templatesDir, `${name}.css`), 'utf8'); + return html.replace('/* CSS will be injected here during build */', css); +} + +function openBrowser(url) { + try { + if (process.platform === 'win32') { + spawn('cmd', ['/c', 'start', '', url], { stdio: 'ignore', detached: true }).unref(); + return; + } + if (process.platform === 'darwin') { + spawn('open', [url], { stdio: 'ignore', detached: true }).unref(); + return; + } + const browsers = ['google-chrome', 'google-chrome-stable', 'chromium-browser', 'chromium']; + for (const browser of browsers) { + try { + execFileSync('which', [browser], { stdio: 'ignore' }); + spawn(browser, [url], { stdio: 'ignore', detached: true }).unref(); + return; + } catch (_e) { + /* not found, try next */ + } + } + spawn('xdg-open', [url], { stdio: 'ignore', detached: true }).unref(); + } catch (_err) { + console.log(`Open ${url} in your browser`); + } +} + +/* ----------------------------- warning scenarios --------------------------- */ + +const now = Date.now(); +const ts = (minutesAgo = 0) => new Date(now - minutesAgo * 60_000).toISOString(); +const fmt = (iso) => new Date(iso).toLocaleString(); + +const sampleBlocks = { + mismatch: { + id: 'block_mismatch_1', + status: 'FILE_UNTRUSTED', + assetType: 'asset', + fileKey: '/app.js', + url: 'https://example.com/app.js', + reason: 'HASH_MISMATCH', + expectedHashes: ['sha256-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa='], + actualHash: 'sha256-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb=', + occurrenceCount: 2, + timestamp: ts(5), + formattedTimestamp: fmt(ts(5)), + view: { + title: 'File content tampered', + expectedLabel: 'Expected hash', + actualLabel: 'Actual hash', + }, + }, + notFound: { + id: 'block_notfound_1', + status: 'FILE_UNTRUSTED', + reason: 'NOT_IN_MANIFEST', + assetType: 'asset', + fileKey: '/vendor/injected.js', + url: 'https://example.com/vendor/injected.js', + expectedHashes: [], + actualHash: 'sha256-ccccccccccccccccccccccccccccccccccccccccccc=', + occurrenceCount: 1, + timestamp: ts(4), + formattedTimestamp: fmt(ts(4)), + view: { title: 'File not listed in trusted manifest', actualLabel: 'Actual hash' }, + }, + deniedByRule: { + id: 'block_denied_1', + status: 'DENIED_BY_RULE', + assetType: 'asset', + fileKey: '/analytics/tracker.js', + url: 'https://example.com/analytics/tracker.js', + expectedHashes: [], + actualHash: null, + occurrenceCount: 1, + timestamp: ts(3), + formattedTimestamp: fmt(ts(3)), + view: { title: 'File blocked by security rule' }, + }, + errorFetch: { + id: 'block_error_1', + status: 'ERROR', + assetType: 'asset', + fileKey: '/critical.js', + url: 'https://example.com/critical.js', + reason: 'FETCH_BAD_STATUS', + httpStatus: 500, + expectedHashes: [], + actualHash: null, + occurrenceCount: 1, + timestamp: ts(2), + formattedTimestamp: fmt(ts(2)), + view: { title: 'Verification failed: non-OK HTTP status' }, + }, + signerChanged: { + id: 'block_signer_1', + status: 'MANIFEST_UNTRUSTED', + assetType: 'manifest', + fileKey: '/integrity-manifest.json', + url: 'https://example.com/integrity-manifest.json', + reason: 'SIGNER_CHANGED', + expectedHashes: [ + 'noble-secp256k1-recovered-eth:0x0d5b81e9bd4d6ab0a0487ea9fe161a4152b11625', + ], + actualHash: 'noble-secp256k1-recovered-eth:0xa4d9aa32b1c60e1a2396eb49a81a92936ab1abc2', + occurrenceCount: 1, + timestamp: ts(1), + formattedTimestamp: fmt(ts(1)), + view: { + title: 'Manifest signer changed since pinning', + expectedLabel: 'Pinned signer', + actualLabel: 'Current signer', + }, + }, + signatureMismatch: { + id: 'block_sigmismatch_1', + status: 'MANIFEST_UNTRUSTED', + assetType: 'manifest', + fileKey: '/integrity-manifest.json', + url: 'https://example.com/integrity-manifest.json', + reason: 'SIGNATURE_MISMATCH', + expectedHashes: [ + 'noble-secp256k1-recovered-eth:0x0d5b81e9bd4d6ab0a0487ea9fe161a4152b11625', + ], + actualHash: 'noble-secp256k1-recovered-eth:0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef', + occurrenceCount: 1, + timestamp: ts(6), + formattedTimestamp: fmt(ts(6)), + view: { + title: 'Manifest signature identity does not match pinned signer', + expectedLabel: 'Pinned signer', + actualLabel: 'Recovered signer', + }, + }, + configError: { + id: 'block_config_1', + status: 'MANIFEST_UNTRUSTED', + assetType: 'manifest', + fileKey: '/integrity-manifest.json', + url: 'https://example.com/integrity-manifest.json', + reason: 'CONFIG_ERROR', + expectedHashes: [], + actualHash: null, + occurrenceCount: 1, + timestamp: ts(7), + formattedTimestamp: fmt(ts(7)), + view: { title: 'DappFence manifest is misconfigured' }, + }, + unsupportedSignature: { + id: 'block_unsupported_1', + status: 'MANIFEST_UNTRUSTED', + assetType: 'manifest', + fileKey: '/integrity-manifest.json', + url: 'https://example.com/integrity-manifest.json', + reason: 'UNSUPPORTED_SIGNATURE', + expectedHashes: [], + actualHash: null, + occurrenceCount: 1, + timestamp: ts(8), + formattedTimestamp: fmt(ts(8)), + view: { title: 'Manifest signature uses an unsupported algorithm' }, + }, + signatureError: { + id: 'block_sigerror_1', + status: 'MANIFEST_UNTRUSTED', + assetType: 'manifest', + fileKey: '/integrity-manifest.json', + url: 'https://example.com/integrity-manifest.json', + reason: 'SIGNATURE_ERROR', + expectedHashes: [], + actualHash: null, + occurrenceCount: 1, + timestamp: ts(9), + formattedTimestamp: fmt(ts(9)), + view: { title: 'Manifest signature verification threw an error' }, + }, + manifestParseError: { + id: 'block_parse_1', + status: 'MANIFEST_UNTRUSTED', + assetType: 'manifest', + fileKey: '/integrity-manifest.json', + url: 'https://example.com/integrity-manifest.json', + reason: 'MANIFEST_PARSE_ERROR', + expectedHashes: [], + actualHash: null, + occurrenceCount: 1, + timestamp: ts(10), + formattedTimestamp: fmt(ts(10)), + view: { title: 'Manifest could not be parsed' }, + }, + manifestFetchBadStatus: { + id: 'block_mfbadstatus_1', + status: 'MANIFEST_UNTRUSTED', + assetType: 'manifest', + fileKey: '/integrity-manifest.json', + url: 'https://example.com/integrity-manifest.json', + reason: 'MANIFEST_FETCH_BAD_STATUS', + httpStatus: 503, + expectedHashes: [], + actualHash: null, + occurrenceCount: 1, + timestamp: ts(11), + formattedTimestamp: fmt(ts(11)), + view: { title: 'Manifest fetch returned a non-OK status' }, + }, + manifestFetchException: { + id: 'block_mfexception_1', + status: 'MANIFEST_UNTRUSTED', + assetType: 'manifest', + fileKey: '/integrity-manifest.json', + url: 'https://example.com/integrity-manifest.json', + reason: 'MANIFEST_FETCH_EXCEPTION', + expectedHashes: [], + actualHash: null, + occurrenceCount: 1, + timestamp: ts(12), + formattedTimestamp: fmt(ts(12)), + view: { title: 'Manifest fetch threw an exception' }, + }, + errorNullResponse: { + id: 'block_err_null_1', + status: 'ERROR', + assetType: 'asset', + fileKey: '/data/profile.json', + url: 'https://example.com/data/profile.json', + reason: 'NULL_RESPONSE', + expectedHashes: [], + actualHash: null, + occurrenceCount: 1, + timestamp: ts(13), + formattedTimestamp: fmt(ts(13)), + view: { title: 'Verification failed: no response received' }, + }, + errorBodyUnreadable: { + id: 'block_err_body_1', + status: 'ERROR', + assetType: 'asset', + fileKey: '/images/hero.png', + url: 'https://example.com/images/hero.png', + reason: 'BODY_UNREADABLE', + expectedHashes: [], + actualHash: null, + occurrenceCount: 1, + timestamp: ts(14), + formattedTimestamp: fmt(ts(14)), + view: { title: 'Verification failed: response body unreadable' }, + }, + errorFetchException: { + id: 'block_err_fetch_1', + status: 'ERROR', + assetType: 'asset', + fileKey: '/api/token.js', + url: 'https://example.com/api/token.js', + reason: 'FETCH_EXCEPTION', + expectedHashes: [], + actualHash: null, + occurrenceCount: 1, + timestamp: ts(15), + formattedTimestamp: fmt(ts(15)), + view: { title: 'Verification failed: fetch threw' }, + }, + errorNoManifest: { + id: 'block_err_nomanifest_1', + status: 'ERROR', + assetType: 'asset', + fileKey: '/late-bound.js', + url: 'https://example.com/late-bound.js', + reason: 'NO_MANIFEST_AVAILABLE', + expectedHashes: [], + actualHash: null, + occurrenceCount: 1, + timestamp: ts(16), + formattedTimestamp: fmt(ts(16)), + view: { title: 'Verification failed: no trusted manifest available' }, + }, + serviceWorkerTampered: { + id: 'block_sw_1', + status: 'FILE_UNTRUSTED', + assetType: 'service-worker', + fileKey: '/sw_app.js', + url: 'https://example.com/sw_app.js', + reason: 'HASH_MISMATCH', + expectedHashes: ['sha256-swexpectedxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx='], + actualHash: 'sha256-swattackerxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx=', + occurrenceCount: 1, + timestamp: ts(17), + formattedTimestamp: fmt(ts(17)), + view: { + title: 'File content tampered', + expectedLabel: 'Expected hash', + actualLabel: 'Actual hash', + }, + }, +}; + +const massTamperPaths = [ + '/app.js', + '/vendor.js', + '/runtime.js', + '/polyfills.js', + '/main.css', + '/vendor.css', + '/assets/logo.svg', + '/assets/icons.woff2', + '/js/analytics.js', + '/js/wallet.js', + '/js/sdk-bundle.js', + '/js/third-party/react.production.min.js', + '/js/third-party/react-dom.production.min.js', + '/js/third-party/ethers.min.js', + '/js/third-party/web3.min.js', + '/service-worker.js', + '/manifest.webmanifest', + '/offline.html', + '/favicon.ico', +]; + +const massTamperBlocks = massTamperPaths.map((fileKey, i) => ({ + id: `block_tamper_${i}`, + status: 'FILE_UNTRUSTED', + assetType: 'asset', + fileKey, + url: `https://example.com${fileKey}`, + reason: 'HASH_MISMATCH', + expectedHashes: [ + `sha256-expected${i.toString().padStart(2, '0')}xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx=`, + ], + actualHash: `sha256-attacker${i.toString().padStart(2, '0')}xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx=`, + occurrenceCount: 1 + (i % 3), + timestamp: ts(10 + i), + formattedTimestamp: fmt(ts(10 + i)), + view: { + title: 'File content tampered', + expectedLabel: 'Expected hash', + actualLabel: 'Actual hash', + }, +})); + +const scenarios = { + all: Object.values(sampleBlocks), + mismatch: [sampleBlocks.mismatch, sampleBlocks.notFound], + signer: [sampleBlocks.signerChanged], + mixed: [sampleBlocks.mismatch, sampleBlocks.signerChanged, sampleBlocks.deniedByRule], + allAssetTypes: [ + sampleBlocks.mismatch, + sampleBlocks.serviceWorkerTampered, + sampleBlocks.signerChanged, + ], + massTamper: massTamperBlocks, + siteCompromised: [sampleBlocks.signerChanged, ...massTamperBlocks], + empty: [], +}; + +function deriveSummary(blocks) { + const hasManifest = blocks.some((b) => b.assetType === 'manifest'); + if (hasManifest) { + return { + subtitle: 'Manifest Trust Issue', + message: + "DappFence could not establish the authenticity of this site's manifest. " + + 'This can indicate a signer change, a signature mismatch, or a manifest ' + + 'configuration problem. Loading the site may expose you to untrusted content.', + }; + } + return { + subtitle: 'Potentially Malicious Content Blocked', + message: + 'DappFence has detected that content on this page has been modified and may be unsafe. ' + + 'This protection prevents potentially malicious code from running in your browser.', + }; +} + +/* --------------------------------- renderers ------------------------------- */ + +function renderWarningScenario(scenarioName) { + const blocks = scenarios[scenarioName] ?? scenarios.all; + const config = { + apiToken: 'preview-token', + activeBlocks: blocks, + summary: deriveSummary(blocks), + autoConfirmSiteLock: false, + }; + const encoded = encodeURIComponent(JSON.stringify(config)); + const scriptTag = ``; + return loadTemplate('security-warning').replace( + /