Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions scanner/gateway/readiness-origin.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,18 @@ function handle(req,res){
const blocks=Array.from({length:count},(_,index)=>`<p>Controlled paragraph ${index}</p>`).join('\n');
res.writeHead(200,{'Content-Type':'text/html'});res.end(retentionPage.replace('<!-- retention-blocks -->',blocks));return;
}
const noticePage = {
'/verification-screen': '<main><p>example.com is verifying your browser…</p></main>',
'/sign-in-screen': '<main><h1 hidden>Welcome</h1><h1>Sign in</h1><form hidden><input type="password"></form><form><label>Password<input type="password"></label><button>Sign in</button></form></main>',
'/ordinary-sign-in-link': '<nav><a href="/sign-in-screen">Sign in</a></nav><main><h1>Checking your browser</h1><p>' +
'Learn how to check your browser version.' +
'</p><form hidden><input type="password"></form></main>',
}[path];
if (typeof noticePage === 'string') {
res.writeHead(200, {'Content-Type': 'text/html; charset=utf-8'});
res.end('<!doctype html><html lang="en"><title>Controlled scan</title>' + 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;}
Expand Down
25 changes: 25 additions & 0 deletions scanner/public-report.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
5 changes: 3 additions & 2 deletions scanner/public-worker.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? '[]');
Expand Down Expand Up @@ -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))) {
Expand All @@ -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,
Expand Down
11 changes: 11 additions & 0 deletions scripts/preview-browser.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
7 changes: 7 additions & 0 deletions server/publicScanner.integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'},
]) {
Expand Down Expand Up @@ -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);
Expand Down
16 changes: 16 additions & 0 deletions server/scanner/publicPolicy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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__', '<script>', null, {}]) {
assert.throws(() => mapPublicReport({ ...raw, pageNotice }), /Invalid scanned page notice/);
}
assert.throws(() => mapPublicReport(Object.assign(Object.create({ pageNotice: 'sign-in' }), raw)), /Invalid scanned page notice/);
});
11 changes: 9 additions & 2 deletions server/scanner/publicScanner.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { navigationRejection, redirectMessage } from '../../scanner/gateway/navigation.mjs';
import { readScanPageNotice, scanPageNoticeText } from '../../shared/scanPageNotice.js';
import { readScanCoverage } from '../../shared/scanCoverage.js';
import { httpStatusMessage, readDiagnostic, classifyFailure } from '../../scanner/gateway/diagnostics.mjs';
import { randomUUID } from 'node:crypto';
Expand All @@ -10,13 +11,19 @@ import { mapWorkerReport } from './fixtureScanner.js';

export function mapPublicReport(raw: unknown, requestedUrl?: string) {
const report = mapWorkerReport(raw, 'public');
const result = raw as { pageLoadIncomplete?: unknown; url?: unknown; coverage?: { blockedRequests?: unknown; failedRequests?: unknown; omittedRules?: unknown; omittedNodes?: unknown } };
const result = raw as { pageLoadIncomplete?: unknown; pageNotice?: unknown; url?: unknown; coverage?: { blockedRequests?: unknown; failedRequests?: unknown; omittedRules?: unknown; omittedNodes?: unknown } };
if(result.pageLoadIncomplete!==undefined&&result.pageLoadIncomplete!==true)throw new Error('Invalid page loading state.');
const pageNotice = readScanPageNotice(result.pageNotice);
if (result.pageNotice !== undefined && (!Object.hasOwn(result, 'pageNotice') || !pageNotice)) {
throw new Error('Invalid scanned page notice.');
}
const noticeText = scanPageNoticeText(pageNotice);
const finalUrl = validatePublicUrl(result.url);
if (requestedUrl && navigationRejection(requestedUrl, finalUrl)) throw new Error('Invalid final page.');
const coverage = readScanCoverage(result.coverage);
if (!coverage) throw new Error('Invalid scan coverage.');
return { ...report, ...(result.pageLoadIncomplete===true?{pageLoadIncomplete:true as const}:{}), mode: 'public' as const, coverage, url: finalUrl, ...(requestedUrl ? { requestedUrl: validatePublicUrl(requestedUrl) } : {}), limitations: [
return { ...report, ...(pageNotice ? { pageNotice } : {}), ...(result.pageLoadIncomplete===true?{pageLoadIncomplete:true as const}:{}), mode: 'public' as const, coverage, url: finalUrl, ...(requestedUrl ? { requestedUrl: validatePublicUrl(requestedUrl) } : {}), limitations: [
...(noticeText ? [noticeText] : []),
...(result.pageLoadIncomplete===true?['Some page resources were still loading when the scan began. Unfinished loads were stopped, so this partial report covers only the rendered content.']:[]),
'Only the initial top-level page at the default viewport was scanned; embedded frames and interactive states are excluded.',
`${coverage.blockedRequests} requests were blocked by policy and ${coverage.failedRequests} requests failed (including gateway denials). Missing resources can change the result.`,
Expand Down
15 changes: 15 additions & 0 deletions shared/scanPageNotice.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
const notices = {
'browser-verification': 'KindPath received a browser verification screen. The findings and any preview describe that screen.',
'sign-in': 'KindPath received a sign-in screen. The findings and any preview describe that screen.',
};

export type ScanPageNotice = keyof typeof notices;

export function readScanPageNotice(value: unknown): ScanPageNotice | undefined {
return value === 'browser-verification' || value === 'sign-in' ? value : undefined;
}

export function scanPageNoticeText(value: unknown): string | undefined {
const notice = readScanPageNotice(value);
return notice && notices[notice];
}
2 changes: 2 additions & 0 deletions shared/types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { ScanPageNotice } from './scanPageNotice';
import type { PreviewFixtureId } from './fixtures';

export interface FindingElement {
Expand All @@ -19,6 +20,7 @@ export interface ScanCoverage {
}
export interface Report {
pageLoadIncomplete?: true;
pageNotice?: ScanPageNotice;
coverage?: ScanCoverage;
mode: 'demo' | 'fixture' | 'public';
requestedUrl?: string;
Expand Down
Loading
Loading