diff --git a/README.md b/README.md index eb925b3..e01d43e 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,8 @@ Images are independently validated and embedded in sandboxed, opaque-origin ifra | POST | `/api/scan-fixture` | Scan a fixed built-in page | | POST | `/api/analyze` | Explicit illustrative example; never fetches its URL | +For successful scans, KindPath labels clearly identifiable browser verification and sign-in screens in the report and preview. The API and JSON download include an optional `pageNotice` code and a plain-language limitation. Detection uses the rendered page and can miss unfamiliar screens. Findings and previews describe the page KindPath received. + A successful real scan has `mode: "public"`, `requestedUrl`, final `url`, `scanId`, findings, scanner metadata and coverage. `sitePreview` contains the same identity, `kind: "image"`, dimensions, truncation flag, capture timestamp, guarded HTML and limitations. If capture alone fails, `previewUnavailable: true` replaces `sitePreview`; the report limitations explain this. Cleanup failure overrides successful results and capture/status explanations. ## Invite-only staging diff --git a/scanner/gateway/readiness-origin.mjs b/scanner/gateway/readiness-origin.mjs index c00d5e1..a4b0d7a 100644 --- a/scanner/gateway/readiness-origin.mjs +++ b/scanner/gateway/readiness-origin.mjs @@ -44,6 +44,18 @@ function handle(req,res){ const blocks=Array.from({length:count},(_,index)=>`

Controlled paragraph ${index}

`).join('\n'); res.writeHead(200,{'Content-Type':'text/html'});res.end(retentionPage.replace('',blocks));return; } + const noticePage = { + '/verification-screen': '

example.com is verifying your browser…

', + '/sign-in-screen': '

Welcome

Sign in

', + '/ordinary-sign-in-link': '

Checking your browser

' + + 'Learn how to check your browser version.' + + '

', + }[path]; + if (typeof noticePage === 'string') { + res.writeHead(200, {'Content-Type': 'text/html; charset=utf-8'}); + res.end('Controlled scan' + noticePage); + return; + } if(path==='/missing'){res.writeHead(404,{'Content-Type':'text/plain'});res.end('Controlled missing page');return;} if(path==='/redirect' && req.headers.host==='example.com'){res.writeHead(302,{Location:'http://www.example.com/redirect'});res.end();return;} if(path==='/blocked-redirect'||path==='/subresource-redirect'){res.writeHead(302,{Location:'http://10.0.0.1/private'});res.end();return;} diff --git a/scanner/public-report.mjs b/scanner/public-report.mjs index 14be3dc..e3c42b3 100644 --- a/scanner/public-report.mjs +++ b/scanner/public-report.mjs @@ -26,3 +26,28 @@ export function summarizeRules(rules) { })), }; } + +// Runs inside the guarded browser. Return only a fixed code, never page text. +export function detectScanPageNotice() { + const normalize = text => text.trim().replace(/\s+/g, ' ').toLowerCase(); + const visible = element => element.checkVisibility({ opacityProperty: true, visibilityProperty: true }); + const main = document.querySelector('main, [role="main"]') ?? document.body; + if (!main || !visible(main)) return undefined; + + const heading = [...main.querySelectorAll('h1')].find(visible); + const headingText = heading ? normalize(heading.innerText) : ''; + const verification = /^(?:checking your browser|verify (?:that )?you are human|verifying (?:your browser|that you are human)|(?:[a-z0-9.-]+ )?is verifying your browser)[.!…]*$/; + const rendered = main.innerText; + if (rendered.length <= 200) { + const text = normalize(rendered); + const supportingText = text.startsWith(headingText) ? text.slice(headingText.length).trim() : ''; + if (verification.test(text) || (verification.test(headingText) && /^(?:please wait|this may take a few seconds)\b/.test(supportingText))) { + return 'browser-verification'; + } + } + + if (/^(?:sign in|log in|login)(?: to (?:your account|[a-z0-9][a-z0-9 .&'-]{0,60}))?[.!]?$/.test(headingText)) { + if ([...main.querySelectorAll('form input[type="password"]')].some(visible)) return 'sign-in'; + } + return undefined; +} diff --git a/scanner/public-worker.mjs b/scanner/public-worker.mjs index 3e2f31c..c0c5c59 100644 --- a/scanner/public-worker.mjs +++ b/scanner/public-worker.mjs @@ -9,7 +9,7 @@ import { chromium } from 'playwright'; import { validateTarget } from './gateway/policy.mjs'; import { navigationRejection } from './gateway/navigation.mjs'; import { installNavigationGuard } from './gateway/navigation-guard.mjs'; -import { analyzePublicPage, summarizeRules } from './public-report.mjs'; +import { analyzePublicPage, detectScanPageNotice, summarizeRules } from './public-report.mjs'; const socketPath = '/run/kindpath/egress.sock'; const allowed = JSON.parse(process.env.KINDPATH_ALLOWED_HOSTS ?? '[]'); @@ -170,6 +170,7 @@ try { validateTarget(scannedUrl, allowed); const scannedDocument=await page.evaluateHandle(()=>document); stage = 'accessibility-analysis'; + const pageNotice = await page.evaluate(detectScanPageNotice).catch(() => undefined); const result = await analyzePublicPage(page); // Do not label findings from one document with another URL, including same-URL reloads. if (navigationGuard.failed || navigationGuard.failure || redirectFailure || pageChanged || page.url() !== scannedUrl || result.url !== scannedUrl || !(await page.evaluate(original=>original===document,scannedDocument))) { @@ -184,7 +185,7 @@ try { stage = 'worker-result'; const violations = summarizeRules(result.violations); const incomplete = summarizeRules(result.incomplete); - process.stdout.write(JSON.stringify({ fixture: 'public', url: scannedUrl, ...(pageLoadIncomplete?{pageLoadIncomplete:true}:{}), ...(preview ? {preview} : {}),...(captureFailure ? {captureFailure} : {}), + process.stdout.write(JSON.stringify({ fixture: 'public', url: scannedUrl, ...(pageNotice ? { pageNotice } : {}), ...(pageLoadIncomplete?{pageLoadIncomplete:true}:{}), ...(preview ? {preview} : {}),...(captureFailure ? {captureFailure} : {}), coverage: { blockedRequests, failedRequests, omittedRules: violations.omittedRules + incomplete.omittedRules, omittedNodes: violations.omittedNodes + incomplete.omittedNodes }, engine: result.testEngine.version, violations: violations.rules, incomplete: incomplete.rules, diff --git a/scripts/preview-browser.mjs b/scripts/preview-browser.mjs index ed193db..0782402 100644 --- a/scripts/preview-browser.mjs +++ b/scripts/preview-browser.mjs @@ -86,6 +86,7 @@ try { assert.equal(exported.report.generatedAt, expected.generatedAt); assert.deepEqual(exported.report.coverage,coverage ?? undefined); assert.equal(exported.report.pageLoadIncomplete,expected.pageLoadIncomplete); + assert.equal(exported.report.pageNotice,expected.pageNotice); assert.deepEqual(exported.report.findings, expected.findings); assert.deepEqual(exported.report.scanner, expected.scanner); assert.deepEqual(exported.report.manualChecks, expected.manualChecks); @@ -708,10 +709,20 @@ try { await publicButton.click();await page.locator('.results').waitFor(); await page.getByRole('region',{name:'Scan coverage',exact:true}).getByText(/Unfinished loads were stopped, so this partial report/).waitFor(); await assertDownload(publicResponse); + for (const [pageNotice, screen] of [['browser-verification', 'browser verification'], ['sign-in', 'sign-in']]) { + publicResponse = {...publicReport, pageNotice, sitePreview: data.sitePreview}; + await publicButton.click(); + const notice = page.getByRole('status').filter({hasText: `KindPath received a ${screen} screen. The findings and any preview describe that screen.`}); + await notice.waitFor(); + await page.getByRole('button', {name: /Visual preview/}).click(); + await notice.waitFor(); + await assertDownload(publicResponse); + } // Browser-rendered previews: decoded pixels, fitting, scroll access, filters and inert hover areas. publicResponse={...publicReport,scanId:data.imagePreview.scanId,sitePreview:data.imagePreview}; await publicButton.click();await page.locator('.results').waitFor(); await page.getByRole('button',{name:/Visual preview/}).click(); + assert.equal(await page.getByRole('status').filter({hasText: /KindPath received a .* screen/}).count(), 0); const imageFrames=page.locator('iframe'); for(let i=0;i<2;i++){ const frame=page.frameLocator('iframe').nth(i); diff --git a/server/publicScanner.integration.ts b/server/publicScanner.integration.ts index 010966e..782b39e 100644 --- a/server/publicScanner.integration.ts +++ b/server/publicScanner.integration.ts @@ -72,6 +72,9 @@ for (const scenario of [ {path:'/indented-content',final:'/indented-content'}, {path:'/indented-budget',final:'/indented-budget'}, {path:'/components',final:'/components'}, {path:'/body-contrast',final:'/body-contrast'}, {path:'/pending-script',final:'/pending-script'}, {path:'/slow-script',final:'/slow-script'}, {path:'/script-pending-load',final:'/pending-script'}, {path:'/pending-document',final:'/pending-document'}, + {path:'/verification-screen',final:'/verification-screen'}, + {path:'/sign-in-screen',final:'/sign-in-screen'}, + {path:'/ordinary-sign-in-link',final:'/ordinary-sign-in-link'}, {path:'/forms',final:'/forms'}, {path:'/raster',final:'/raster'}, {path:'/missing',final:'/missing'}, {path:'/same-url-history',final:'/same-url-history'}, {path:'/same-url-reload',final:'/same-url-reload'}, ]) { @@ -142,6 +145,10 @@ test(`URL scan/capture handles controlled ${scenario.path} without public access } assert.equal(report.mode, 'public'); assert.equal(report.requestedUrl, target); assert.equal(report.url,'http://'+(scenario.path==='/redirect'?'www.example.com':'example.com')+scenario.final); if (scenario.path === '/redirect') for (const id of ['image-alt','label','button-name']) assert.ok(report.findings.some((finding: {id:string}) => finding.id === id)); + const pageNotice = scenario.path === '/verification-screen' ? 'browser-verification' + : scenario.path === '/sign-in-screen' ? 'sign-in' : undefined; + assert.equal(report.pageNotice, pageNotice); + if (pageNotice) assert.ok(report.limitations.some((text: string) => text.includes('describe that screen'))); assert.ok(report.limitations.some((text: string) => /requests were blocked by policy/.test(text))); assert.equal(lookups, scenario.path==='/redirect'?2:1); assert.equal(PUBLIC_SCANNING_ENABLED, false); const { PUBLIC_CAPTURE_ENABLED } = await import('./scanner/publicPolicy.js'); assert.equal(PUBLIC_CAPTURE_ENABLED,false); diff --git a/server/scanner/publicPolicy.test.ts b/server/scanner/publicPolicy.test.ts index 4b00f06..e8ed532 100644 --- a/server/scanner/publicPolicy.test.ts +++ b/server/scanner/publicPolicy.test.ts @@ -54,3 +54,19 @@ test('partial page loading is explicit and malformed loading markers are rejecte assert.equal(mapPublicReport(raw).pageLoadIncomplete,undefined); for(const value of [false,null,'true',1,{}])assert.throws(()=>mapPublicReport({...raw,pageLoadIncomplete:value})); }); + + +test('public report page notices accept only fixed own codes', () => { + const raw = { fixture: 'public', url: 'https://example.com/', engine: 'test', violations: [], + incomplete: [], passes: 1, inapplicable: 0, coverage: { blockedRequests: 0, failedRequests: 0, omittedRules: 0, omittedNodes: 0 } }; + assert.equal(mapPublicReport(raw).pageNotice, undefined); + for (const pageNotice of ['sign-in', 'browser-verification']) { + const report = mapPublicReport({ ...raw, pageNotice }); + assert.equal(report.pageNotice, pageNotice); + assert.ok(report.limitations.some(text => text.includes('describe that screen'))); + } + for (const pageNotice of ['constructor', '__proto__', '