mirror of
https://github.com/yaklang/yaklang-chrome-extension.git
synced 2026-09-25 20:51:52 +08:00
fix(browser): improve transform mapping and browser analysis capabilities
CI / Test and build (push) Canceled after 0s
CI / Test and build (push) Canceled after 0s
This commit is contained in:
@@ -48,6 +48,7 @@ import {
|
||||
type RecordingTraceContext,
|
||||
type RecordingTraceRuntime,
|
||||
} from '@/features/browser-recording/main-world/trace';
|
||||
import { recordingExpiryDelay } from '@/features/browser-recording/expiry';
|
||||
import { RetainedCallBudget } from '@/features/browser-recording/main-world/retained-call-budget';
|
||||
import { estimateRetainedCallBytes } from '@/features/browser-recording/main-world/retained-value-size';
|
||||
import { ExtensionError } from '@/shared/errors';
|
||||
@@ -673,7 +674,14 @@ export default defineUnlistedScript(() => {
|
||||
if (active || !startedAt) return;
|
||||
active = true;
|
||||
installObservers();
|
||||
if (options.expiresAt) expiryTimer = window.setTimeout(stop, Math.max(0, options.expiresAt - Date.now()));
|
||||
scheduleExpiry();
|
||||
}
|
||||
|
||||
function scheduleExpiry(): void {
|
||||
const delay = recordingExpiryDelay(options.expiresAt);
|
||||
if (delay === undefined) return;
|
||||
if (delay === 0) { stop(); return; }
|
||||
expiryTimer = window.setTimeout(stop, delay);
|
||||
}
|
||||
|
||||
function snapshot(limit = options.maxEntries): RecorderSnapshot {
|
||||
@@ -863,7 +871,7 @@ export default defineUnlistedScript(() => {
|
||||
reseedFingerprints();
|
||||
active = true;
|
||||
installObservers();
|
||||
if (options.expiresAt) expiryTimer = window.setTimeout(stop, Math.max(0, options.expiresAt - Date.now()));
|
||||
scheduleExpiry();
|
||||
return snapshot();
|
||||
}
|
||||
if (command === 'resume') {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { browser } from 'wxt/browser';
|
||||
import * as v from 'valibot';
|
||||
import {
|
||||
createRecordedPageCallable,
|
||||
getBrowserRecording,
|
||||
} from '@/features/browser-recording/service';
|
||||
import { recordingSnapshotForScope } from '@/features/browser-recording/redaction';
|
||||
@@ -34,6 +35,7 @@ import {
|
||||
type BrowserTransformProfileProposalResult,
|
||||
type BrowserTransformProfileValidationResult,
|
||||
type BrowserTransformValidationDraft,
|
||||
type BrowserTransformDirectionName,
|
||||
} from '@/types/models';
|
||||
|
||||
const MAX_TRACE_EVENTS = 80;
|
||||
@@ -478,6 +480,37 @@ export async function latestBrowserTransformValidation(
|
||||
return draft || memoryValidationDrafts.get(key) || null;
|
||||
}
|
||||
|
||||
export async function browserTransformValidationById(
|
||||
validationId: string,
|
||||
): Promise<BrowserTransformValidationDraft> {
|
||||
const now = Date.now();
|
||||
const stored = await readStoredValidationDrafts();
|
||||
const drafts = pruneValidationDrafts(stored, now);
|
||||
if (Object.keys(drafts).length !== Object.keys(stored).length) {
|
||||
validationDraftStorageQueue = validationDraftStorageQueue.then(() => writeStoredValidationDrafts(drafts));
|
||||
await validationDraftStorageQueue;
|
||||
}
|
||||
const draft = Object.values(drafts).find((item) => item.id === validationId)
|
||||
|| [...memoryValidationDrafts.values()].find((item) => item.id === validationId && item.expiresAt > now);
|
||||
if (!draft) {
|
||||
throw new ExtensionError('validation_draft_stale', '验证草稿不存在或已经过期,请重新生成并验证');
|
||||
}
|
||||
return draft;
|
||||
}
|
||||
|
||||
export async function executeBrowserTransformValidation(
|
||||
validationId: string,
|
||||
direction: BrowserTransformDirectionName,
|
||||
packet: BrowserTransformPacket,
|
||||
): Promise<BrowserTransformExecution> {
|
||||
const draft = await browserTransformValidationById(validationId);
|
||||
const { profile, execution } = await validateBrowserTransformProfile(draft.profile, packet, {
|
||||
direction,
|
||||
profileId: `transient-${validationId}`,
|
||||
});
|
||||
return { ...execution, explanation: profile.explanation, proofLevel: draft.proofLevel };
|
||||
}
|
||||
|
||||
export async function discardBrowserTransformValidation(
|
||||
target: BrowserTarget,
|
||||
validationId: string,
|
||||
@@ -801,7 +834,7 @@ export function comparePacketWithInferenceCandidate(
|
||||
check(
|
||||
checks,
|
||||
'body-shape',
|
||||
'已关联的线上字段存在且没有二次 JSON 包装',
|
||||
'线上字段结构一致(不验证加密前的输入内容)',
|
||||
bodyFieldsPresent,
|
||||
actualShape.signature,
|
||||
bodyFields,
|
||||
@@ -1045,6 +1078,7 @@ export async function proposeBrowserTransformProfile(
|
||||
callableId: string,
|
||||
inputPaths?: string[],
|
||||
name?: string,
|
||||
packet?: BrowserTransformPacket,
|
||||
): Promise<BrowserTransformProfileProposalResult> {
|
||||
const [snapshot, callables, tab, frame] = await Promise.all([
|
||||
getBrowserRecording(target, 500, false),
|
||||
@@ -1082,7 +1116,7 @@ export async function proposeBrowserTransformProfile(
|
||||
lastAccessed: tab.lastAccessed,
|
||||
};
|
||||
const requestEvent = evidence.requestEvent;
|
||||
let profile = createBrowserTransformProfileInput(tabInfo, requestEvent, callable, candidate);
|
||||
let profile = createBrowserTransformProfileInput(tabInfo, requestEvent, callable, candidate, inputPaths ? undefined : packet);
|
||||
profile = {
|
||||
...profile,
|
||||
name: name || profile.name,
|
||||
@@ -1129,7 +1163,7 @@ export async function proposeBrowserTransformProfile(
|
||||
? callable.transaction ? 'captured-request-transaction' : 'validated-callable-envelope'
|
||||
: 'recording-evidence',
|
||||
},
|
||||
next: '调用 profile.validate;验证成功后由用户在插件中确认保存,AI 不直接持久化配置',
|
||||
next: '调用 profile.validate;验证成功后可立即用 validationDraft.id 做一次临时明文 HTTP 测试,只有复用配置才需要用户在插件中确认保存',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1196,7 +1230,7 @@ export async function validateBrowserTransformProposal(
|
||||
proofLevel,
|
||||
normalizedProfile,
|
||||
generated,
|
||||
execution,
|
||||
execution: { ...execution, explanation: normalized.explanation },
|
||||
comparison,
|
||||
validationDraft: validationDraft ? {
|
||||
contractVersion: validationDraft.contractVersion,
|
||||
@@ -1206,9 +1240,11 @@ export async function validateBrowserTransformProposal(
|
||||
} : undefined,
|
||||
next: comparison
|
||||
? comparison.equivalent
|
||||
? '确定性验证通过;插件已生成待用户确认的明文网关草稿'
|
||||
? comparison.mode === 'exact'
|
||||
? '样本报文对比通过;尚未发送业务测试请求,使用 validationDraft.id 调用 browser.http.test'
|
||||
: '仅结构校验通过,不证明明文输入、加密语义或业务成功;使用 validationDraft.id 调用 browser.http.test 验证,请勿原样重复 prepare'
|
||||
: '数据包对比未通过;检查输入映射或重新选择页面函数'
|
||||
: 'Pipeline 已真实回放并生成待确认草稿;如需更强证明,请提供一份浏览器线上请求进行结构对比',
|
||||
: 'Pipeline 已真实回放;可将 validationDraft.id 直接交给 browser.http.test。如需更强证明,请提供一份浏览器线上请求进行结构对比',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1228,6 +1264,7 @@ export async function validateInferredBrowserTransformProfile(
|
||||
callableId,
|
||||
inputPaths,
|
||||
name,
|
||||
packet,
|
||||
);
|
||||
return validateBrowserTransformProposal(
|
||||
proposal.profile,
|
||||
@@ -1237,3 +1274,35 @@ export async function validateInferredBrowserTransformProfile(
|
||||
candidateId,
|
||||
);
|
||||
}
|
||||
|
||||
export async function prepareCapturedBrowserTransformProfile(
|
||||
target: BrowserTarget,
|
||||
candidateId: string,
|
||||
packet: BrowserTransformPacket,
|
||||
inputPaths?: string[],
|
||||
name?: string,
|
||||
): Promise<BrowserTransformProfileValidationResult> {
|
||||
const candidate = await resolveStagedProfileCandidate(target, candidateId);
|
||||
const source = [candidate.source, ...candidate.sources]
|
||||
.find((item) => item.callHandleId);
|
||||
if (!source?.callHandleId) {
|
||||
throw new ExtensionError(
|
||||
'gateway_capture_required',
|
||||
'本次操作没有捕获到可回放的页面函数,请在原页面重新执行一次浏览器加解密检查',
|
||||
);
|
||||
}
|
||||
const existing = (await listPageCallables(target))
|
||||
.find((item) => item.provenance.eventId === source.eventId);
|
||||
const callable = existing || await createRecordedPageCallable(target, {
|
||||
callHandleId: source.callHandleId,
|
||||
name: name || candidate.summary.slice(0, 120) || 'Captured page transform',
|
||||
});
|
||||
return validateInferredBrowserTransformProfile(
|
||||
target,
|
||||
candidate.id,
|
||||
callable.id,
|
||||
packet,
|
||||
inputPaths,
|
||||
name,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const fixture = vi.hoisted(() => ({
|
||||
executeScript: vi.fn(),
|
||||
startRecording: vi.fn(),
|
||||
stopRecording: vi.fn(),
|
||||
startNetwork: vi.fn(),
|
||||
listNetwork: vi.fn(),
|
||||
stopNetwork: vi.fn(),
|
||||
act: vi.fn(),
|
||||
context: vi.fn(),
|
||||
stageEvidence: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('wxt/browser', () => ({
|
||||
browser: { scripting: { executeScript: fixture.executeScript } },
|
||||
}));
|
||||
vi.mock('@/features/browser-recording/service', () => ({
|
||||
startBrowserRecording: fixture.startRecording,
|
||||
stopBrowserRecording: fixture.stopRecording,
|
||||
}));
|
||||
vi.mock('@/features/network-capture/service', () => ({
|
||||
startNetworkCapture: fixture.startNetwork,
|
||||
listNetworkRequests: fixture.listNetwork,
|
||||
stopNetworkCapture: fixture.stopNetwork,
|
||||
}));
|
||||
vi.mock('@/features/page-context/service', () => ({
|
||||
actOnPageNode: fixture.act,
|
||||
capturePageContext: fixture.context,
|
||||
}));
|
||||
vi.mock('@/features/browser-analysis/service', () => ({
|
||||
listRecordingTraces: vi.fn(() => [{ id: 'trace-1', cryptoCount: 1 }]),
|
||||
stageBrowserProfileEvidence: fixture.stageEvidence,
|
||||
}));
|
||||
vi.mock('@/platform/browser/targets', () => ({
|
||||
scriptingTarget: vi.fn((target) => ({ tabId: target.tabId, documentIds: [target.documentId] })),
|
||||
}));
|
||||
|
||||
describe('atomic page crypto inspection', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
fixture.executeScript
|
||||
.mockResolvedValueOnce([{ result: true }])
|
||||
.mockResolvedValueOnce([{ result: [{ type: 'alert', message: 'done', decision: 'auto_dismissed', timestamp: 2 }] }]);
|
||||
fixture.startRecording.mockResolvedValue({});
|
||||
fixture.stopRecording.mockResolvedValue({
|
||||
status: { target: { tabId: 7, frameId: 0, documentId: 'doc-1' }, active: false, documentAvailable: true, count: 1, droppedCount: 0 },
|
||||
events: [{
|
||||
id: 'event-1', sequence: 1, timestamp: 1, recordingId: 'recording-1', traceId: 'trace-1',
|
||||
kind: 'crypto', operation: 'AES.encrypt',
|
||||
crypto: { adapterId: 'cryptojs', providerKind: 'library', family: 'symmetric', operation: 'AES.encrypt', mode: 'CBC', padding: 'Pkcs7' },
|
||||
inputs: [], outputs: [], sensitiveCaptured: true,
|
||||
}],
|
||||
traces: [], links: [], callables: [], profileCandidates: [{
|
||||
id: 'candidate-1', direction: 'request', summary: 'login request',
|
||||
confidence: { score: 0.95, level: 'high' },
|
||||
source: { eventId: 'event-1', callHandleId: 'handle-1' },
|
||||
sources: [],
|
||||
request: {
|
||||
method: 'POST', url: 'https://example.test/api', bodyFormat: 'json',
|
||||
mappings: [{ sourceEventId: 'event-1', destination: '$body.username' }],
|
||||
},
|
||||
}],
|
||||
});
|
||||
fixture.startNetwork.mockResolvedValue({});
|
||||
fixture.listNetwork.mockResolvedValue([{
|
||||
id: 'request-1', requestId: 'devtools-1', tabId: 7, frameId: 0,
|
||||
url: 'https://example.test/api', method: 'POST', resourceType: 'xmlhttprequest',
|
||||
startedAt: 1, completedAt: 2, statusCode: 200,
|
||||
requestHeadersCaptured: false, requestBodyCaptured: true,
|
||||
requestBody: { encoding: 'utf8', data: '{"cipher":"abc"}', byteLength: 16, truncated: false },
|
||||
redirects: [],
|
||||
}]);
|
||||
fixture.stopNetwork.mockResolvedValue({});
|
||||
fixture.act.mockResolvedValue({ action: 'click', status: 'dispatched', dispatchedAt: 1, node: { nodeId: 'n1' } });
|
||||
fixture.context.mockResolvedValue({
|
||||
captureId: 'capture-2',
|
||||
target: { tabId: 7, frameId: 0, documentId: 'doc-1' },
|
||||
authentication: { state: 'authenticated' },
|
||||
document: {
|
||||
title: 'Crypto lab', url: 'https://example.test/', readyState: 'complete', forms: [],
|
||||
interactive: [{ nodeId: 'n2', role: 'button', name: 'Next operation', visible: true }],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it('captures one click, crypto evidence, request, and modal dialog in one call', async () => {
|
||||
const { inspectPageCryptoOperation } = await import('./inspect');
|
||||
const result = await inspectPageCryptoOperation(
|
||||
{ tabId: 7, frameId: 0, documentId: 'doc-1' },
|
||||
{ captureId: 'capture-1', nodeId: 'n1', settleMs: 250 },
|
||||
{ grantId: 'paired', expiresAt: Date.now() + 60_000 },
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
state: 'observed',
|
||||
dialogs: [{ type: 'alert', message: 'done', decision: 'auto_dismissed' }],
|
||||
dialogHandling: { autoDismissedAlerts: 1, autoAcceptedConfirms: 0, autoSubmittedPrompts: 0, count: 1, navigationInferred: false },
|
||||
postAction: {
|
||||
sameDocument: true,
|
||||
captureId: 'capture-2',
|
||||
document: { interactive: [{ nodeId: 'n2', name: 'Next operation' }] },
|
||||
},
|
||||
recording: { count: 1, events: [{ kind: 'crypto', operation: 'AES.encrypt' }] },
|
||||
network: { count: 1, requests: [{ method: 'POST', statusCode: 200 }] },
|
||||
gatewayPreparation: {
|
||||
state: 'ready', candidateId: 'candidate-1',
|
||||
request: { method: 'POST', destinations: ['$body.username'] },
|
||||
},
|
||||
});
|
||||
expect(fixture.act).toHaveBeenCalledOnce();
|
||||
expect(fixture.stopRecording).toHaveBeenCalledOnce();
|
||||
expect(fixture.stopNetwork).toHaveBeenCalledOnce();
|
||||
expect(fixture.stageEvidence).toHaveBeenCalledOnce();
|
||||
expect(fixture.context).toHaveBeenCalledWith({ includeDom: true }, { tabId: 7, frameId: 0 });
|
||||
});
|
||||
|
||||
it('waits for a delayed request instead of treating an empty capture as idle', async () => {
|
||||
vi.useFakeTimers();
|
||||
const startedAt = Date.now();
|
||||
const delayedRequest = {
|
||||
id: 'request-delayed', requestId: 'devtools-delayed', tabId: 7, frameId: 2,
|
||||
url: 'https://example.test/delayed', method: 'POST', resourceType: 'xmlhttprequest',
|
||||
startedAt: 1, completedAt: 2, statusCode: 200,
|
||||
requestHeadersCaptured: false, requestBodyCaptured: false, redirects: [],
|
||||
};
|
||||
fixture.listNetwork.mockImplementation(async () => (Date.now() - startedAt >= 900 ? [delayedRequest] : []));
|
||||
const { inspectPageCryptoOperation } = await import('./inspect');
|
||||
let settled = false;
|
||||
const pending = inspectPageCryptoOperation(
|
||||
{ tabId: 7, frameId: 2, documentId: 'doc-frame' },
|
||||
{ captureId: 'capture-1', nodeId: 'n1', settleMs: 1_500 },
|
||||
{ grantId: 'paired', expiresAt: Date.now() + 60_000 },
|
||||
).finally(() => { settled = true; });
|
||||
|
||||
await vi.advanceTimersByTimeAsync(800);
|
||||
expect(settled).toBe(false);
|
||||
await vi.advanceTimersByTimeAsync(800);
|
||||
const result = await pending;
|
||||
expect(result).toMatchObject({ network: { count: 1, requests: [{ id: 'request-delayed' }] } });
|
||||
expect(fixture.context).toHaveBeenCalledWith({ includeDom: true }, { tabId: 7, frameId: 2 });
|
||||
});
|
||||
|
||||
it('handles alert, confirm, and prompt without blocking the page', async () => {
|
||||
const originalAlert = globalThis.alert;
|
||||
const originalConfirm = globalThis.confirm;
|
||||
const originalPrompt = globalThis.prompt;
|
||||
const alert = vi.fn();
|
||||
globalThis.alert = alert;
|
||||
const confirm = vi.fn(() => false);
|
||||
const prompt = vi.fn(() => 'typed value');
|
||||
globalThis.confirm = confirm;
|
||||
globalThis.prompt = prompt;
|
||||
try {
|
||||
const { installPageDialogCapture, restorePageDialogCapture } = await import('./inspect');
|
||||
expect(installPageDialogCapture()).toBe(true);
|
||||
globalThis.alert('notice');
|
||||
expect(globalThis.confirm('continue?')).toBe(true);
|
||||
expect(globalThis.prompt('name?')).toBe('');
|
||||
expect(restorePageDialogCapture()).toMatchObject([
|
||||
{ type: 'alert', decision: 'auto_dismissed' },
|
||||
{ type: 'confirm', decision: 'auto_accepted' },
|
||||
{ type: 'prompt', decision: 'auto_submitted' },
|
||||
]);
|
||||
expect(alert).not.toHaveBeenCalled();
|
||||
expect(confirm).not.toHaveBeenCalled();
|
||||
expect(prompt).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
globalThis.alert = originalAlert;
|
||||
globalThis.confirm = originalConfirm;
|
||||
globalThis.prompt = originalPrompt;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,265 @@
|
||||
import {
|
||||
startBrowserRecording,
|
||||
stopBrowserRecording,
|
||||
} from '@/features/browser-recording/service';
|
||||
import {
|
||||
listRecordingTraces,
|
||||
stageBrowserProfileEvidence,
|
||||
} from '@/features/browser-analysis/service';
|
||||
import {
|
||||
listNetworkRequests,
|
||||
startNetworkCapture,
|
||||
stopNetworkCapture,
|
||||
} from '@/features/network-capture/service';
|
||||
import { actOnPageNode, capturePageContext } from '@/features/page-context/service';
|
||||
import {
|
||||
beginPageDialogCapture,
|
||||
endPageDialogCapture,
|
||||
installPageDialogCapture,
|
||||
restorePageDialogCapture,
|
||||
} from '@/features/page-context/dialogs';
|
||||
import { ExtensionError } from '@/shared/errors';
|
||||
import type {
|
||||
BrowserRecordingEvent,
|
||||
BrowserRecordingSnapshot,
|
||||
BrowserTarget,
|
||||
NetworkRequestRecord,
|
||||
PageDialog,
|
||||
PageNodeActionResult,
|
||||
} from '@/types/models';
|
||||
|
||||
type InspectionOwner = { grantId: string; expiresAt: number };
|
||||
export { installPageDialogCapture, restorePageDialogCapture };
|
||||
|
||||
function clipped(value: string | undefined, max: number): string | undefined {
|
||||
return value === undefined ? undefined : value.slice(0, max);
|
||||
}
|
||||
|
||||
function compactEvent(event: BrowserRecordingEvent): Record<string, unknown> {
|
||||
return {
|
||||
id: event.id,
|
||||
traceId: event.traceId,
|
||||
kind: event.kind,
|
||||
operation: event.operation,
|
||||
label: event.label,
|
||||
crypto: event.crypto,
|
||||
transform: event.transform,
|
||||
direction: event.direction,
|
||||
method: event.method,
|
||||
statusCode: event.statusCode,
|
||||
url: clipped(event.url, 2_048),
|
||||
dataType: event.dataType,
|
||||
byteLength: event.byteLength,
|
||||
resultByteLength: event.resultByteLength,
|
||||
scriptUrl: clipped(event.scriptUrl, 2_048),
|
||||
stack: clipped(event.stack, 512),
|
||||
callableCapable: event.callableCapable,
|
||||
callHandleId: event.callHandleId,
|
||||
arguments: event.arguments?.slice(0, 8),
|
||||
inputs: event.inputs.slice(0, 12),
|
||||
outputs: event.outputs.slice(0, 12),
|
||||
inputPreview: clipped(event.inputPreview, 1_024),
|
||||
outputPreview: clipped(event.outputPreview, 1_024),
|
||||
error: event.error,
|
||||
};
|
||||
}
|
||||
|
||||
function compactRequest(request: NetworkRequestRecord): Record<string, unknown> {
|
||||
return {
|
||||
id: request.id,
|
||||
method: request.method,
|
||||
url: clipped(request.url, 4_096),
|
||||
resourceType: request.resourceType,
|
||||
statusCode: request.statusCode,
|
||||
durationMs: request.durationMs,
|
||||
error: request.error,
|
||||
requestBody: request.requestBody && {
|
||||
...request.requestBody,
|
||||
data: clipped(request.requestBody.data, 2_048),
|
||||
},
|
||||
responseContentType: request.responseContentType,
|
||||
responseSize: request.responseSize,
|
||||
};
|
||||
}
|
||||
|
||||
export function summarizeCryptoInspection(
|
||||
snapshot: BrowserRecordingSnapshot,
|
||||
requests: NetworkRequestRecord[],
|
||||
): Record<string, unknown> {
|
||||
const events = snapshot.events.slice(-16);
|
||||
const cryptoCount = events.filter((event) => event.kind === 'crypto').length;
|
||||
const transformCount = events.filter((event) => event.kind === 'transform').length;
|
||||
const state = cryptoCount + transformCount > 0
|
||||
? 'observed'
|
||||
: events.length + requests.length > 0
|
||||
? 'boundary_only'
|
||||
: 'no_evidence';
|
||||
return {
|
||||
state,
|
||||
summary: state === 'observed'
|
||||
? `已观测到 ${cryptoCount} 次密码调用和 ${transformCount} 次编码/序列化转换`
|
||||
: state === 'boundary_only'
|
||||
? '已观测到页面或网络边界,但未命中已知加解密适配器'
|
||||
: '本次页面操作没有产生可分析证据',
|
||||
recording: {
|
||||
count: snapshot.status.count,
|
||||
droppedCount: snapshot.status.droppedCount,
|
||||
events: events.map(compactEvent),
|
||||
traces: listRecordingTraces(snapshot, 6),
|
||||
},
|
||||
network: {
|
||||
count: requests.length,
|
||||
requests: requests.slice(0, 8).map(compactRequest),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function waitForInspectionIdle(target: BrowserTarget, maxWaitMs: number): Promise<NetworkRequestRecord[]> {
|
||||
const startedAt = Date.now();
|
||||
let lastChangedAt = startedAt;
|
||||
let previousSignature = '';
|
||||
let observedActivity = false;
|
||||
let requests: NetworkRequestRecord[] = [];
|
||||
while (Date.now() - startedAt < maxWaitMs) {
|
||||
requests = await listNetworkRequests(target, 20);
|
||||
const signature = requests.map((item) => `${item.id}:${item.completedAt || ''}:${item.error || ''}`).join('|');
|
||||
if (signature !== previousSignature) {
|
||||
previousSignature = signature;
|
||||
lastChangedAt = Date.now();
|
||||
}
|
||||
if (requests.length > 0) observedActivity = true;
|
||||
const allFinished = requests.every((item) => item.completedAt !== undefined || Boolean(item.error));
|
||||
if (observedActivity && Date.now() - startedAt >= 500 && allFinished && Date.now() - lastChangedAt >= 350) break;
|
||||
await new Promise((resolve) => globalThis.setTimeout(resolve, 100));
|
||||
}
|
||||
return requests;
|
||||
}
|
||||
|
||||
export async function inspectPageCryptoOperation(
|
||||
target: BrowserTarget,
|
||||
input: { captureId: string; nodeId: string; settleMs?: number },
|
||||
owner: InspectionOwner,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const startedAt = Date.now();
|
||||
const settleMs = Math.max(250, Math.min(input.settleMs || 4_000, 5_000));
|
||||
const warnings: string[] = [];
|
||||
let action: PageNodeActionResult | undefined;
|
||||
let snapshot: BrowserRecordingSnapshot | undefined;
|
||||
let requests: NetworkRequestRecord[] = [];
|
||||
let dialogs: PageDialog[] = [];
|
||||
let postAction: Record<string, unknown> | undefined;
|
||||
let recordingStarted = false;
|
||||
let networkStarted = false;
|
||||
let dialogCaptureOwned = false;
|
||||
|
||||
dialogCaptureOwned = await beginPageDialogCapture(target);
|
||||
try {
|
||||
await startNetworkCapture(target, {
|
||||
captureHeaders: false,
|
||||
captureBody: true,
|
||||
maxEntries: 40,
|
||||
maxBodyBytes: 8_192,
|
||||
}, {
|
||||
kind: 'grant',
|
||||
grantId: owner.grantId,
|
||||
expiresAt: owner.expiresAt,
|
||||
followSameOriginNavigation: true,
|
||||
});
|
||||
networkStarted = true;
|
||||
await startBrowserRecording(target, {
|
||||
captureValues: true,
|
||||
maxEntries: 160,
|
||||
maxValueBytes: 4_096,
|
||||
expiresAt: owner.expiresAt,
|
||||
}, { kind: 'grant', grantId: owner.grantId, expiresAt: owner.expiresAt });
|
||||
recordingStarted = true;
|
||||
|
||||
action = await actOnPageNode(input.captureId, input.nodeId, 'click', target);
|
||||
requests = await waitForInspectionIdle(target, settleMs);
|
||||
snapshot = await stopBrowserRecording(target, true);
|
||||
recordingStarted = false;
|
||||
} finally {
|
||||
if (recordingStarted) {
|
||||
try { snapshot = await stopBrowserRecording(target, true); } catch (error) {
|
||||
warnings.push(`停止页面录制失败: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
if (networkStarted) {
|
||||
try {
|
||||
if (!requests.length) requests = await listNetworkRequests(target, 20);
|
||||
await stopNetworkCapture(target);
|
||||
} catch (error) {
|
||||
warnings.push(`停止网络观察失败: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
dialogs = await endPageDialogCapture(target, dialogCaptureOwned);
|
||||
try {
|
||||
const context = await capturePageContext({ includeDom: true }, {
|
||||
tabId: target.tabId,
|
||||
frameId: target.frameId,
|
||||
});
|
||||
postAction = {
|
||||
sameDocument: Boolean(target.documentId && context.target.documentId === target.documentId),
|
||||
captureId: context.captureId,
|
||||
target: context.target,
|
||||
authentication: context.authentication,
|
||||
document: {
|
||||
title: context.document.title,
|
||||
url: context.document.url,
|
||||
readyState: context.document.readyState,
|
||||
forms: context.document.forms,
|
||||
interactive: context.document.interactive,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
warnings.push(`采集操作后页面状态失败: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!snapshot || !action) {
|
||||
throw new ExtensionError('crypto_inspection_incomplete', '未能完整执行页面加解密检查');
|
||||
}
|
||||
await stageBrowserProfileEvidence(snapshot);
|
||||
const preparation = snapshot.profileCandidates
|
||||
.filter((candidate) => candidate.direction === 'request' && [candidate.source, ...candidate.sources]
|
||||
.some((source) => Boolean(source.callHandleId)))
|
||||
.sort((left, right) => right.confidence.score - left.confidence.score)[0];
|
||||
const evidence = summarizeCryptoInspection(snapshot, requests);
|
||||
return {
|
||||
version: 1,
|
||||
target,
|
||||
trigger: { captureId: input.captureId, nodeId: input.nodeId, action: 'click' },
|
||||
action,
|
||||
startedAt,
|
||||
completedAt: Date.now(),
|
||||
dialogs,
|
||||
dialogHandling: {
|
||||
strategy: 'nonblocking-local-dialog-defaults',
|
||||
autoDismissedAlerts: dialogs.filter((dialog) => dialog.type === 'alert').length,
|
||||
autoAcceptedConfirms: dialogs.filter((dialog) => dialog.type === 'confirm').length,
|
||||
autoSubmittedPrompts: dialogs.filter((dialog) => dialog.type === 'prompt').length,
|
||||
count: dialogs.length,
|
||||
navigationInferred: false,
|
||||
},
|
||||
postAction,
|
||||
gatewayPreparation: preparation ? {
|
||||
state: 'ready',
|
||||
candidateId: preparation.id,
|
||||
direction: preparation.direction,
|
||||
confidence: preparation.confidence,
|
||||
request: {
|
||||
method: preparation.request.method,
|
||||
url: preparation.request.url,
|
||||
bodyFormat: preparation.request.bodyFormat,
|
||||
destinations: preparation.request.mappings.map((mapping) => mapping.destination).filter(Boolean),
|
||||
},
|
||||
next: '需要明文 HTTP 测试时,直接调用 browser.transform.prepare;不要再调用 recording、callable、debugger 或 profile 底层能力',
|
||||
} : {
|
||||
state: 'unavailable',
|
||||
next: '本次证据可用于分析,但不足以生成明文转换;继续使用当前页面,不要重新打开网站',
|
||||
},
|
||||
warnings,
|
||||
...evidence,
|
||||
purpose: '仅分析这一次页面操作;未创建、验证或保存明文网关 Profile',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { recordingExpiryDelay } from './expiry';
|
||||
|
||||
describe('recording expiry timer', () => {
|
||||
it('does not overflow a permanent paired-browser grant into an immediate timeout', () => {
|
||||
expect(recordingExpiryDelay(Number.MAX_SAFE_INTEGER, 1_000)).toBeUndefined();
|
||||
expect(recordingExpiryDelay(11_000, 1_000)).toBe(10_000);
|
||||
expect(recordingExpiryDelay(999, 1_000)).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
const MAX_BROWSER_TIMER_MS = 0x7fffffff;
|
||||
|
||||
export function recordingExpiryDelay(expiresAt: number | undefined, now = Date.now()): number | undefined {
|
||||
if (expiresAt === undefined) return undefined;
|
||||
const delay = expiresAt - now;
|
||||
if (delay <= 0) return 0;
|
||||
return delay <= MAX_BROWSER_TIMER_MS ? delay : undefined;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
BrowserProfileInferenceCandidate,
|
||||
} from '@/types/models';
|
||||
import { createBrowserTransformProfileInput } from './profile-draft';
|
||||
import { executeTransformDirection } from './mapping';
|
||||
|
||||
const tab: ActiveTabInfo = {
|
||||
id: 7,
|
||||
@@ -66,6 +67,26 @@ const responseCandidate = {
|
||||
} satisfies BrowserProfileInferenceCandidate;
|
||||
|
||||
describe('browser transform profile draft', () => {
|
||||
it('reads only the captured form field for a single string input and rejects ambiguous fields', async () => {
|
||||
const candidate = { ...responseCandidate, direction: 'request' as const,
|
||||
request: { ...responseCandidate.request, bodyFormat: 'form' as const, serialization: 'form-field' as const } };
|
||||
const packet = { method: 'POST', url: candidate.request.url,
|
||||
headers: [{ name: 'Content-Type', value: 'application/x-www-form-urlencoded; charset=utf-8' }],
|
||||
bodyBase64: btoa('encryptedData={"username":"admin","password":"admin123"}') };
|
||||
const profile = createBrowserTransformProfileInput(tab, undefined, callable, candidate, packet);
|
||||
expect(profile.request.nodes.filter((node) => node.kind === 'context.read')).toMatchObject([{ path: 'body.encryptedData' }]);
|
||||
let received: unknown[] = [];
|
||||
await executeTransformDirection('test', 'request', profile.request, packet, async (callableId, args) => {
|
||||
received = args;
|
||||
return { callableId, type: 'string', preview: 'cipher', value: 'cipher', durationMs: 1 };
|
||||
});
|
||||
expect(received).toEqual(['{"username":"admin","password":"admin123"}']);
|
||||
for (const body of ['username=admin', 'encryptedData=a&encryptedData=b']) {
|
||||
expect(() => createBrowserTransformProfileInput(tab, undefined, callable, candidate, { ...packet, bodyBase64: btoa(body) })).toThrow(/input_paths/);
|
||||
}
|
||||
const jsonPacket = { ...packet, headers: [{ name: 'Content-Type', value: 'application/json' }], bodyBase64: btoa('{"username":"admin"}') };
|
||||
expect(createBrowserTransformProfileInput(tab, undefined, callable, candidate, jsonPacket).request.nodes[0]).toMatchObject({ path: 'body' });
|
||||
});
|
||||
it('compiles an inferred response decryptor into the response direction', () => {
|
||||
const profile = createBrowserTransformProfileInput(tab, undefined, callable, responseCandidate);
|
||||
|
||||
|
||||
@@ -5,7 +5,9 @@ import type {
|
||||
BrowserRecordingEvent,
|
||||
BrowserTransformDirection,
|
||||
BrowserTransformProfileInput,
|
||||
BrowserTransformPacket,
|
||||
} from '@/types/models';
|
||||
import { ExtensionError } from '@/shared/errors';
|
||||
import { compileGuidedTransform, defaultGuidedTransform, type GuidedTransformOutputKind } from './guided';
|
||||
|
||||
interface RequestRouteSource {
|
||||
@@ -26,7 +28,7 @@ function emptyDirection(enabled = false): BrowserTransformDirection {
|
||||
return { enabled, nodes: [] };
|
||||
}
|
||||
|
||||
function candidateGuidance(candidate?: BrowserProfileInferenceCandidate): {
|
||||
function candidateGuidance(candidate?: BrowserProfileInferenceCandidate, callable?: BrowserPageCallable, packet?: BrowserTransformPacket): {
|
||||
inputPaths?: string[];
|
||||
outputKind?: GuidedTransformOutputKind;
|
||||
outputField?: string;
|
||||
@@ -37,7 +39,23 @@ function candidateGuidance(candidate?: BrowserProfileInferenceCandidate): {
|
||||
if (candidate?.direction === 'response') {
|
||||
return { inputPaths: [destination], outputKind: 'body' };
|
||||
}
|
||||
if (serialization === 'form-field') return { outputKind: 'form-field', outputField: destination.slice(5) };
|
||||
if (serialization === 'form-field') {
|
||||
let inputPaths: string[] | undefined;
|
||||
const slots = callable?.inputSlots.filter((slot) => !slot.retained);
|
||||
if (packet && slots?.length === 1 && slots[0].dataType === 'string') {
|
||||
const contentType = packet.headers.find((header) => header.name.toLowerCase() === 'content-type')?.value.split(';')[0].trim().toLowerCase();
|
||||
if (contentType === 'application/x-www-form-urlencoded') {
|
||||
const body = new TextDecoder().decode(Uint8Array.from(atob(packet.bodyBase64), (char) => char.charCodeAt(0)));
|
||||
const fields = new URLSearchParams(body);
|
||||
const destinations = new Set(candidate?.request.mappings.map((mapping) => mapping.destination));
|
||||
if (destinations.size > 1 || fields.getAll(destination.slice(5)).length !== 1) {
|
||||
throw new ExtensionError('profile_input_mismatch', '无法唯一确定表单明文输入,请显式指定 input_paths;尚未发送请求');
|
||||
}
|
||||
inputPaths = [destination];
|
||||
}
|
||||
}
|
||||
return { inputPaths, outputKind: 'form-field', outputField: destination.slice(5) };
|
||||
}
|
||||
if (serialization === 'json-field') return { outputKind: 'json-field', outputField: destination.slice(5) };
|
||||
if (serialization === 'header') return { outputKind: 'header', outputField: destination.slice(7) };
|
||||
if (serialization === 'query') return { outputKind: 'query', outputField: destination.slice(6) };
|
||||
@@ -49,8 +67,9 @@ export function createBrowserTransformProfileInput(
|
||||
event?: BrowserRecordingEvent,
|
||||
callable?: BrowserPageCallable,
|
||||
candidate?: BrowserProfileInferenceCandidate,
|
||||
packet?: BrowserTransformPacket,
|
||||
): BrowserTransformProfileInput {
|
||||
const guide = defaultGuidedTransform(callable, candidateGuidance(candidate));
|
||||
const guide = defaultGuidedTransform(callable, candidateGuidance(candidate, callable, packet));
|
||||
const compiled = callable ? compileGuidedTransform(guide, callable) : emptyDirection(true);
|
||||
const responseDirection = candidate?.direction === 'response';
|
||||
const routeEvent = candidate ? {
|
||||
|
||||
@@ -855,9 +855,10 @@ export async function executeBrowserTransform(input: BrowserTransformExecuteInpu
|
||||
direction,
|
||||
input.packet,
|
||||
);
|
||||
return input.direction === 'request'
|
||||
const result = input.direction === 'request'
|
||||
? await bindOnlineTransactionSession(profile, execution)
|
||||
: execution;
|
||||
return { ...result, explanation: profile.explanation };
|
||||
} finally {
|
||||
leave();
|
||||
}
|
||||
@@ -866,6 +867,7 @@ export async function executeBrowserTransform(input: BrowserTransformExecuteInpu
|
||||
export async function validateBrowserTransformProfile(
|
||||
input: BrowserTransformProfileInput,
|
||||
packet: BrowserTransformExecuteInput['packet'],
|
||||
options: { direction?: BrowserTransformDirectionName; profileId?: string } = {},
|
||||
): Promise<{ profile: BrowserTransformProfile; execution: BrowserTransformExecution }> {
|
||||
const target = await resolveDocumentTarget(input.target);
|
||||
const isolation = await currentTransformIsolation(target);
|
||||
@@ -878,19 +880,22 @@ export async function validateBrowserTransformProfile(
|
||||
const normalized = withRequestTransactionBinding(
|
||||
normalizeProfile({
|
||||
...input,
|
||||
id: `validation-${crypto.randomUUID()}`,
|
||||
id: options.profileId || `validation-${crypto.randomUUID()}`,
|
||||
target,
|
||||
maxConcurrency: transactionSafeConcurrency(input, callables),
|
||||
}, isolation),
|
||||
requestTransaction,
|
||||
);
|
||||
const profile = withTransformExplanation(normalized, callables);
|
||||
const directionName: BrowserTransformDirectionName = profile.request.enabled
|
||||
const directionName: BrowserTransformDirectionName = options.direction || (profile.request.enabled
|
||||
? 'request'
|
||||
: profile.response.enabled ? 'response' : 'request';
|
||||
: profile.response.enabled ? 'response' : 'request');
|
||||
const direction = profile[directionName];
|
||||
if (!profile.enabled || !direction.enabled) {
|
||||
throw new ExtensionError('transform_direction_disabled', '候选明文网关没有启用任何转换方向');
|
||||
if (!profile.enabled) {
|
||||
throw new ExtensionError('transform_profile_disabled', '候选明文网关未启用');
|
||||
}
|
||||
if (!direction.enabled) {
|
||||
throw new ExtensionError('transform_direction_disabled', `候选明文网关未启用 ${directionName} 转换方向`);
|
||||
}
|
||||
assertTransformRoute(profile.match.methods, profile.match.urlPattern, packet, profile.origin);
|
||||
assertRequestTransactionPacket(profile, packet);
|
||||
|
||||
@@ -43,7 +43,8 @@ export const NETWORK_CAPABILITY_DOMAIN: CapabilityDomainDefinition = {
|
||||
|
||||
export const RECORDING_CAPABILITY_DOMAIN: CapabilityDomainDefinition = {
|
||||
id: 'recording-callable-debugger',
|
||||
owns: (method) => method.startsWith('browser.recording.')
|
||||
owns: (method) => method === 'browser.crypto.inspect'
|
||||
|| method.startsWith('browser.recording.')
|
||||
|| method.startsWith('browser.callable.')
|
||||
|| method.startsWith('browser.deep_capture.'),
|
||||
};
|
||||
|
||||
@@ -33,10 +33,28 @@ import {
|
||||
stageBrowserProfileEvidence,
|
||||
} from '@/features/browser-analysis/service';
|
||||
import { RECORDING_CAPABILITY_DOMAIN } from '../capability-domains';
|
||||
import { inspectPageCryptoOperation } from '@/features/browser-crypto/inspect';
|
||||
|
||||
export const recordingCapabilityHandler: CapabilityDomainHandler = {
|
||||
...RECORDING_CAPABILITY_DOMAIN,
|
||||
async handle({ method, input, grant }) {
|
||||
if (method === 'browser.crypto.inspect') {
|
||||
for (const scope of [
|
||||
'browser.recording.control',
|
||||
'browser.recording.sensitive.read',
|
||||
'browser.network.capture',
|
||||
'browser.network.sensitive.read',
|
||||
] as const) requireScope(grant, scope);
|
||||
return inspectPageCryptoOperation(
|
||||
await allowedTarget(grant, input),
|
||||
{
|
||||
captureId: String(input.captureId || ''),
|
||||
nodeId: String(input.nodeId || ''),
|
||||
settleMs: typeof input.settleMs === 'number' ? input.settleMs : undefined,
|
||||
},
|
||||
{ grantId: grant.id, expiresAt: grant.expiresAt },
|
||||
);
|
||||
}
|
||||
if (method.startsWith('browser.recording.')) {
|
||||
const target = await allowedTarget(grant, input);
|
||||
if (method === 'browser.recording.trace.list') {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type {
|
||||
BrowserTransformExecuteInput,
|
||||
BrowserTransformPacket,
|
||||
BrowserTransformValidationExecuteInput,
|
||||
} from '@/types/models';
|
||||
import type { CapabilityDomainHandler } from '../capability-context';
|
||||
import { allowedTarget, requireScope } from '../capability-context';
|
||||
@@ -18,7 +19,10 @@ import {
|
||||
} from '@/features/browser-transform/service';
|
||||
import {
|
||||
compareBrowserPackets,
|
||||
browserTransformValidationById,
|
||||
executeBrowserTransformValidation,
|
||||
latestBrowserTransformValidation,
|
||||
prepareCapturedBrowserTransformProfile,
|
||||
proposeBrowserTransformProfile,
|
||||
validateInferredBrowserTransformProfile,
|
||||
} from '@/features/browser-analysis/service';
|
||||
@@ -27,6 +31,17 @@ import { TRANSFORM_CAPABILITY_DOMAIN } from '../capability-domains';
|
||||
export const transformCapabilityHandler: CapabilityDomainHandler = {
|
||||
...TRANSFORM_CAPABILITY_DOMAIN,
|
||||
async handle({ method, input, grant }) {
|
||||
if (method === 'browser.transform.prepare') {
|
||||
requireScope(grant, 'browser.recording.read');
|
||||
requireScope(grant, 'browser.callable.execute');
|
||||
return prepareCapturedBrowserTransformProfile(
|
||||
await allowedTarget(grant, input),
|
||||
String(input.candidateId || ''),
|
||||
input.packet as BrowserTransformPacket,
|
||||
Array.isArray(input.inputPaths) ? input.inputPaths.map(String) : undefined,
|
||||
typeof input.name === 'string' ? input.name : undefined,
|
||||
);
|
||||
}
|
||||
if (method === 'browser.packet.compare') {
|
||||
await allowedTarget(grant, input);
|
||||
return compareBrowserPackets(
|
||||
@@ -123,6 +138,17 @@ export const transformCapabilityHandler: CapabilityDomainHandler = {
|
||||
await allowedTarget(grant, profile.target);
|
||||
return deleteBrowserTransformProfile(profile.id);
|
||||
}
|
||||
if (method === 'browser.transform.validation.execute') {
|
||||
requireScope(grant, 'browser.transform.execute');
|
||||
const executeValidationInput = input as unknown as BrowserTransformValidationExecuteInput;
|
||||
const draft = await browserTransformValidationById(executeValidationInput.validationId);
|
||||
await allowedTarget(grant, draft.profile.target);
|
||||
return executeBrowserTransformValidation(
|
||||
executeValidationInput.validationId,
|
||||
executeValidationInput.direction,
|
||||
executeValidationInput.packet,
|
||||
);
|
||||
}
|
||||
const executeInput = input as unknown as BrowserTransformExecuteInput;
|
||||
const profile = await getBrowserTransformProfile(executeInput.profileId);
|
||||
await allowedTarget(grant, profile.target);
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { browser } from 'wxt/browser';
|
||||
import { scriptingTarget } from '@/platform/browser/targets';
|
||||
import { ExtensionError } from '@/shared/errors';
|
||||
import type { BrowserTarget, PageDialog } from '@/types/models';
|
||||
|
||||
export function installPageDialogCapture(): boolean {
|
||||
const key = Symbol.for('com.yaklang.browser.page-dialogs.v1');
|
||||
const existing = Reflect.get(globalThis, key) as { messages?: unknown[] } | undefined;
|
||||
if (existing?.messages) return false;
|
||||
const messages: PageDialog[] = [];
|
||||
const original = {
|
||||
alert: globalThis.alert,
|
||||
confirm: globalThis.confirm,
|
||||
prompt: globalThis.prompt,
|
||||
};
|
||||
const record = (type: PageDialog['type'], value: unknown, decision: PageDialog['decision']): void => {
|
||||
if (messages.length >= 20) return;
|
||||
let message = '';
|
||||
try { message = String(value ?? ''); } catch { message = '[unprintable]'; }
|
||||
messages.push({ type, message: message.slice(0, 1_000), decision, timestamp: Date.now() });
|
||||
};
|
||||
const replacements = {
|
||||
alert(value?: unknown) {
|
||||
record('alert', value, 'auto_dismissed');
|
||||
},
|
||||
confirm(value?: unknown) {
|
||||
record('confirm', value, 'auto_accepted');
|
||||
return true;
|
||||
},
|
||||
prompt(value?: unknown, defaultValue?: string) {
|
||||
record('prompt', value, 'auto_submitted');
|
||||
return defaultValue || '';
|
||||
},
|
||||
};
|
||||
try {
|
||||
globalThis.alert = replacements.alert;
|
||||
globalThis.confirm = replacements.confirm;
|
||||
globalThis.prompt = replacements.prompt;
|
||||
Reflect.set(globalThis, key, { messages, original, replacements });
|
||||
return true;
|
||||
} catch {
|
||||
globalThis.alert = original.alert;
|
||||
globalThis.confirm = original.confirm;
|
||||
globalThis.prompt = original.prompt;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function restorePageDialogCapture(): PageDialog[] {
|
||||
const key = Symbol.for('com.yaklang.browser.page-dialogs.v1');
|
||||
const capture = Reflect.get(globalThis, key) as {
|
||||
messages?: PageDialog[];
|
||||
original?: { alert: typeof globalThis.alert; confirm: typeof globalThis.confirm; prompt: typeof globalThis.prompt };
|
||||
replacements?: { alert: typeof globalThis.alert; confirm: typeof globalThis.confirm; prompt: typeof globalThis.prompt };
|
||||
} | undefined;
|
||||
if (!capture?.original || !capture.replacements) return [];
|
||||
if (globalThis.alert === capture.replacements.alert) globalThis.alert = capture.original.alert;
|
||||
if (globalThis.confirm === capture.replacements.confirm) globalThis.confirm = capture.original.confirm;
|
||||
if (globalThis.prompt === capture.replacements.prompt) globalThis.prompt = capture.original.prompt;
|
||||
Reflect.deleteProperty(globalThis, key);
|
||||
return Array.isArray(capture.messages) ? capture.messages.slice(0, 20) : [];
|
||||
}
|
||||
|
||||
export async function beginPageDialogCapture(target: BrowserTarget): Promise<boolean> {
|
||||
const result = await browser.scripting.executeScript({
|
||||
target: scriptingTarget(target),
|
||||
world: 'MAIN',
|
||||
func: installPageDialogCapture,
|
||||
});
|
||||
if (result.length !== 1 || typeof result[0].result !== 'boolean') {
|
||||
throw new ExtensionError('dialog_capture_unavailable', '无法安全处理页面弹窗,未执行页面操作');
|
||||
}
|
||||
return result[0].result;
|
||||
}
|
||||
|
||||
export async function endPageDialogCapture(target: BrowserTarget, owned: boolean): Promise<PageDialog[]> {
|
||||
if (!owned) return [];
|
||||
try {
|
||||
const result = await browser.scripting.executeScript({
|
||||
target: scriptingTarget(target),
|
||||
world: 'MAIN',
|
||||
func: restorePageDialogCapture,
|
||||
});
|
||||
return Array.isArray(result[0]?.result) ? result[0].result as PageDialog[] : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { browser } from 'wxt/browser';
|
||||
import type {
|
||||
ActiveTabInfo, BrowserStorageInventory, BrowserTarget, PageAuthenticationSignals, PageContext, PageContextChange,
|
||||
PageContextDiff, PageContextOptions, PageEvalResult, PageNodeAction, PageNodeActionResult,
|
||||
PageFormSummary, PageNodeDetails, PageNodeSummary, PageStorageSummary,
|
||||
PageDialog, PageFormSummary, PageNodeDetails, PageNodeSummary, PageStorageSummary,
|
||||
} from '@/types/models';
|
||||
import { executePageOperation } from '@/features/page-context/execution-adapter';
|
||||
import { getFrameInventory } from '@/features/page-context/frames';
|
||||
@@ -12,6 +12,7 @@ import { listCookies } from '@/features/cookies/service';
|
||||
import { ExtensionError } from '@/shared/errors';
|
||||
import { getTab, resolveDocumentTarget, scriptingTarget } from '@/platform/browser/targets';
|
||||
import { resolveTabCookieStoreId } from '@/platform/browser/isolation';
|
||||
import { beginPageDialogCapture, endPageDialogCapture } from './dialogs';
|
||||
|
||||
async function collectDocumentContext(input: { options: PageContextOptions; captureId: string }) {
|
||||
const MAX_SCANNED_ELEMENTS = 10_000;
|
||||
@@ -738,7 +739,13 @@ function operateRegisteredNode(input: { captureId: string; nodeId: string; opera
|
||||
if (input.operation === 'inspect') return { ok: true as const, node };
|
||||
const control = element as HTMLInputElement;
|
||||
if (input.operation === 'click') {
|
||||
if (control.disabled || element.getAttribute('aria-disabled') === 'true') {
|
||||
const style = getComputedStyle(element);
|
||||
const visible = element.getClientRects().length > 0
|
||||
&& style.display !== 'none'
|
||||
&& style.visibility !== 'hidden'
|
||||
&& style.opacity !== '0'
|
||||
&& style.pointerEvents !== 'none';
|
||||
if (!visible || control.disabled || element.getAttribute('aria-disabled') === 'true') {
|
||||
return { ok: false as const, code: 'node_not_actionable', message: '页面元素当前不可点击' };
|
||||
}
|
||||
const click = (element as HTMLElement).click;
|
||||
@@ -807,8 +814,17 @@ export async function actOnPageNode(
|
||||
input: BrowserTarget | number,
|
||||
value?: string,
|
||||
): Promise<PageNodeActionResult> {
|
||||
const node = await operateNode(captureId, nodeId, action, input, value);
|
||||
return { action, completedAt: Date.now(), node };
|
||||
const target = await resolveDocumentTarget(input);
|
||||
const dialogCaptureOwned = action === 'click' ? await beginPageDialogCapture(target) : false;
|
||||
let node: PageNodeDetails;
|
||||
let dialogs: PageDialog[] = [];
|
||||
try {
|
||||
node = await operateNode(captureId, nodeId, action, target, value);
|
||||
if (dialogCaptureOwned) await new Promise((resolve) => globalThis.setTimeout(resolve, 50));
|
||||
} finally {
|
||||
dialogs = await endPageDialogCapture(target, dialogCaptureOwned);
|
||||
}
|
||||
return { action, status: 'dispatched', dispatchedAt: Date.now(), node, dialogs };
|
||||
}
|
||||
|
||||
export async function invokePageFunction(path: string, args: unknown[], input?: BrowserTarget | number, timeoutMs = 10_000): Promise<PageEvalResult> {
|
||||
|
||||
@@ -131,6 +131,12 @@ describe('Bridge v3 protocol', () => {
|
||||
expect(parseCapabilityParams('browser.recording.trace.list', {
|
||||
tabId: 12, frameId: 0, limit: 20,
|
||||
})).toMatchObject({ limit: 20 });
|
||||
expect(parseCapabilityParams('browser.crypto.inspect', {
|
||||
tabId: 12, captureId: 'capture-1', nodeId: 'n1', settleMs: 2_000,
|
||||
})).toMatchObject({ captureId: 'capture-1', nodeId: 'n1', settleMs: 2_000 });
|
||||
expect(() => parseCapabilityParams('browser.crypto.inspect', {
|
||||
captureId: 'capture-1', nodeId: 'n1', settleMs: 30_000,
|
||||
})).toThrow();
|
||||
expect(parseCapabilityParams('browser.recording.evidence.inspect', {
|
||||
tabId: 12, traceId: 'trace-1', includeValues: false,
|
||||
})).toMatchObject({ traceId: 'trace-1', includeValues: false });
|
||||
@@ -163,6 +169,12 @@ describe('Bridge v3 protocol', () => {
|
||||
candidateId: 'candidate-1',
|
||||
callableId: 'callable-1',
|
||||
});
|
||||
expect(parseCapabilityParams('browser.transform.prepare', {
|
||||
tabId: 12,
|
||||
candidateId: 'candidate-1',
|
||||
inputPaths: ['body'],
|
||||
packet,
|
||||
})).toMatchObject({ candidateId: 'candidate-1', packet });
|
||||
expect(parseCapabilityParams('browser.transform.recovery.start', {
|
||||
id: 'profile-1',
|
||||
})).toMatchObject({ id: 'profile-1' });
|
||||
@@ -186,6 +198,11 @@ describe('Bridge v3 protocol', () => {
|
||||
id: 'profile-1',
|
||||
validationId: 'validation-1',
|
||||
})).toMatchObject({ validationId: 'validation-1' });
|
||||
expect(parseCapabilityParams('browser.transform.validation.execute', {
|
||||
validationId: 'validation-1',
|
||||
direction: 'request',
|
||||
packet,
|
||||
})).toMatchObject({ validationId: 'validation-1', direction: 'request' });
|
||||
expect(() => parseCapabilityParams('browser.profile.validate', {
|
||||
tabId: 12,
|
||||
profile: {},
|
||||
|
||||
@@ -114,6 +114,12 @@ export const capabilityParams = {
|
||||
action: v.picklist(['click', 'focus', 'scroll', 'setValue']),
|
||||
value: v.optional(v.pipe(v.string(), v.maxLength(100_000))),
|
||||
}), v.check((input) => input.action !== 'setValue' || typeof input.value === 'string', 'setValue 操作必须提供 value')),
|
||||
'browser.crypto.inspect': v.strictObject({
|
||||
...targetFields,
|
||||
captureId,
|
||||
nodeId,
|
||||
settleMs: v.optional(v.pipe(v.number(), v.safeInteger(), v.minValue(250), v.maxValue(5_000))),
|
||||
}),
|
||||
'browser.cookies': v.optional(v.strictObject(targetFields)),
|
||||
'browser.takeover': v.optional(v.strictObject(targetFields)),
|
||||
'browser.instance.close': v.optional(v.strictObject({})),
|
||||
@@ -230,6 +236,13 @@ export const capabilityParams = {
|
||||
observed: v.optional(browserTransformPacketSchema),
|
||||
comparisonMode: v.optional(v.picklist(['structure', 'exact'])),
|
||||
}),
|
||||
'browser.transform.prepare': v.strictObject({
|
||||
...targetFields,
|
||||
candidateId: id,
|
||||
inputPaths: v.optional(v.pipe(v.array(valuePath), v.maxLength(64))),
|
||||
name: v.optional(v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(120))),
|
||||
packet: browserTransformPacketSchema,
|
||||
}),
|
||||
'browser.deep_capture.start': v.strictObject({ ...targetFields, matcher: deepCaptureMatcher }),
|
||||
'browser.deep_capture.status': v.optional(v.strictObject(targetFields)),
|
||||
'browser.deep_capture.keepalive': v.optional(v.strictObject(targetFields)),
|
||||
@@ -252,6 +265,11 @@ export const capabilityParams = {
|
||||
'browser.transform.recovery.confirm': v.strictObject({ id, validationId: id }),
|
||||
'browser.transform.recovery.reset': v.strictObject({ id }),
|
||||
'browser.transform.execute': browserTransformExecuteSchema,
|
||||
'browser.transform.validation.execute': v.strictObject({
|
||||
validationId: id,
|
||||
direction: v.picklist(['request', 'response']),
|
||||
packet: browserTransformPacketSchema,
|
||||
}),
|
||||
'browser.invoke': v.strictObject({
|
||||
...targetFields,
|
||||
path: v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(2_048)),
|
||||
|
||||
@@ -35,6 +35,18 @@ describe('versioned Bridge capability catalog', () => {
|
||||
expect(JSON.stringify(evalCapability?.paramsSchema)).toContain('"mode"');
|
||||
expect(JSON.stringify(evalCapability?.paramsSchema)).toContain('"program"');
|
||||
expect(capabilityBaseScope('browser.profile.validate')).toBe('browser.transform.execute');
|
||||
expect(catalog.capabilities.find((capability) => capability.method === 'browser.crypto.inspect')).toMatchObject({
|
||||
domain: 'recording',
|
||||
access: 'execute',
|
||||
scopes: [
|
||||
'browser.dom.write',
|
||||
'browser.recording.control',
|
||||
'browser.recording.sensitive.read',
|
||||
'browser.network.capture',
|
||||
'browser.network.sensitive.read',
|
||||
],
|
||||
targetMode: 'document',
|
||||
});
|
||||
expect(catalog.capabilities.find((capability) => capability.method === 'browser.thumbnail')).toMatchObject({
|
||||
agentVisible: false,
|
||||
});
|
||||
@@ -63,6 +75,14 @@ describe('versioned Bridge capability catalog', () => {
|
||||
targetMode: 'profile',
|
||||
});
|
||||
expect(capabilityBaseScope('browser.transform.recovery.validate')).toBe('browser.transform.execute');
|
||||
expect(catalog.capabilities.find((capability) => capability.method === 'browser.transform.validation.execute')).toMatchObject({
|
||||
domain: 'transform',
|
||||
access: 'execute',
|
||||
scopes: ['browser.transform.execute'],
|
||||
targetMode: 'profile',
|
||||
});
|
||||
expect(capabilityVisibleToAgent('browser.transform.validation.execute')).toBe(true);
|
||||
expect(capabilityVisibleToAgent('browser.transform.prepare')).toBe(true);
|
||||
expect(catalog.capabilities.find((capability) => capability.method === 'browser.isolation.proof')).toMatchObject({
|
||||
domain: 'isolation',
|
||||
access: 'sensitive-read',
|
||||
|
||||
@@ -91,6 +91,18 @@ const CAPABILITY_METADATA = {
|
||||
domain: 'page', access: 'write', summary: '点击、聚焦、滚动或填写稳定页面节点',
|
||||
scopes: ['browser.dom.write'], targetMode: 'document', defaultTimeoutMs: READ_TIMEOUT_MS,
|
||||
},
|
||||
'browser.crypto.inspect': {
|
||||
domain: 'recording', access: 'execute',
|
||||
summary: '原子化触发一个可见页面节点,返回加解密、编码、网络证据及动作后的新页面节点;以非阻塞本地默认值处理 alert/confirm/prompt,并为明文转换准备候选',
|
||||
scopes: [
|
||||
'browser.dom.write',
|
||||
'browser.recording.control',
|
||||
'browser.recording.sensitive.read',
|
||||
'browser.network.capture',
|
||||
'browser.network.sensitive.read',
|
||||
],
|
||||
targetMode: 'document', defaultTimeoutMs: REPLAY_TIMEOUT_MS,
|
||||
},
|
||||
'browser.cookies': {
|
||||
domain: 'page', access: 'sensitive-read', summary: '读取目标页面 Cookie,包括已授权的 HttpOnly 值',
|
||||
scopes: ['browser.cookies.read'], targetMode: 'document', defaultTimeoutMs: READ_TIMEOUT_MS,
|
||||
@@ -162,7 +174,7 @@ const CAPABILITY_METADATA = {
|
||||
scopes: ['browser.network.sensitive.read'], targetMode: 'document', defaultTimeoutMs: REPLAY_TIMEOUT_MS,
|
||||
},
|
||||
'browser.recording.start': {
|
||||
domain: 'recording', access: 'control', summary: '开始业务 Trace 录制;生成新明文网关时先录制一次真实业务操作,再检查候选证据',
|
||||
domain: 'recording', access: 'control', summary: '开始业务 Trace 录制;由插件本地诊断与高层能力使用',
|
||||
scopes: ['browser.recording.control'],
|
||||
conditionalScopes: [{ scope: 'browser.recording.sensitive.read', when: 'captureValues=true' }],
|
||||
targetMode: 'document', defaultTimeoutMs: READ_TIMEOUT_MS,
|
||||
@@ -194,7 +206,7 @@ const CAPABILITY_METADATA = {
|
||||
targetMode: 'document', defaultTimeoutMs: READ_TIMEOUT_MS,
|
||||
},
|
||||
'browser.callable.create': {
|
||||
domain: 'callable', access: 'execute', summary: '从录制句柄或深度捕获 Frame 创建页面函数;生成明文网关 Profile 前需要得到可回放函数',
|
||||
domain: 'callable', access: 'execute', summary: '从录制句柄或深度捕获 Frame 创建页面函数',
|
||||
scopes: ['browser.callable.execute'],
|
||||
conditionalScopes: [{ scope: 'browser.debugger.control', when: 'source=deep-capture' }],
|
||||
targetMode: 'document', defaultTimeoutMs: READ_TIMEOUT_MS,
|
||||
@@ -240,7 +252,7 @@ const CAPABILITY_METADATA = {
|
||||
scopes: ['browser.debugger.control'], targetMode: 'document', defaultTimeoutMs: REPLAY_TIMEOUT_MS,
|
||||
},
|
||||
'browser.transform.profile.list': {
|
||||
domain: 'transform', access: 'read', summary: '明文网关入口:先列出目标页面已有 Profile;已有配置可直接用 transform.execute,无配置再走录制、提案和验证',
|
||||
domain: 'transform', access: 'read', summary: '列出目标页面已有的明文转换;无匹配配置时使用 browser.crypto.inspect 和 browser.transform.prepare',
|
||||
scopes: ['browser.transform.read'], targetMode: 'document', defaultTimeoutMs: READ_TIMEOUT_MS,
|
||||
},
|
||||
'browser.transform.profile.delete': {
|
||||
@@ -277,24 +289,34 @@ const CAPABILITY_METADATA = {
|
||||
domain: 'transform', access: 'execute', summary: '使用已保存的 Profile 对 HTTP 报文执行请求加密或响应解密;它不是网络代理切换',
|
||||
scopes: ['browser.transform.execute'], targetMode: 'profile', defaultTimeoutMs: REPLAY_TIMEOUT_MS,
|
||||
},
|
||||
'browser.transform.validation.execute': {
|
||||
domain: 'transform', access: 'execute', summary: '使用 Agent 已验证的短时草稿执行请求加密或响应解密,不会永久保存 Profile',
|
||||
scopes: ['browser.transform.execute'], targetMode: 'profile', defaultTimeoutMs: REPLAY_TIMEOUT_MS,
|
||||
},
|
||||
'browser.packet.compare': {
|
||||
domain: 'transform', access: 'read', summary: '按结构或精确模式比较两份 HTTP 报文',
|
||||
scopes: ['browser.transform.read'], targetMode: 'document', defaultTimeoutMs: READ_TIMEOUT_MS,
|
||||
},
|
||||
'browser.profile.propose': {
|
||||
domain: 'transform', access: 'read', summary: '从录制候选和页面函数编译未保存的 Profile 提案;下一步必须调用 profile.validate',
|
||||
domain: 'transform', access: 'read', summary: '从录制候选和页面函数编译未保存的 Profile 提案',
|
||||
scopes: ['browser.transform.read', 'browser.recording.read'],
|
||||
targetMode: 'document', defaultTimeoutMs: READ_TIMEOUT_MS,
|
||||
},
|
||||
'browser.profile.validation.latest': {
|
||||
domain: 'transform', access: 'read', summary: '读取当前文档最近的短时验证草稿及本地确认状态;草稿过期后需重新验证',
|
||||
domain: 'transform', access: 'read', summary: '读取当前文档最近的短时验证草稿及本地确认状态',
|
||||
scopes: ['browser.transform.read'], targetMode: 'document', defaultTimeoutMs: READ_TIMEOUT_MS,
|
||||
},
|
||||
'browser.profile.validate': {
|
||||
domain: 'transform', access: 'execute', summary: '确定性执行 Profile 提案并与证据比较;成功后只生成短时草稿,必须由用户在插件本地确认保存',
|
||||
domain: 'transform', access: 'execute', summary: '确定性执行 Profile 提案并与证据比较;保存仍需用户在插件本地确认保存',
|
||||
scopes: ['browser.transform.execute', 'browser.recording.read'],
|
||||
targetMode: 'document', defaultTimeoutMs: REPLAY_TIMEOUT_MS,
|
||||
},
|
||||
'browser.transform.prepare': {
|
||||
domain: 'transform', access: 'execute',
|
||||
summary: '将 browser.crypto.inspect 捕获的候选原子化编译并验证为短时明文转换;不需要 Agent 操作录制、页面函数或 Profile 底层步骤',
|
||||
scopes: ['browser.transform.execute', 'browser.recording.read', 'browser.callable.execute'],
|
||||
targetMode: 'document', defaultTimeoutMs: REPLAY_TIMEOUT_MS,
|
||||
},
|
||||
'browser.invoke': {
|
||||
domain: 'page', access: 'dangerous', summary: '在页面 MAIN world 调用具名函数路径',
|
||||
scopes: ['browser.page.invoke'], targetMode: 'document', defaultTimeoutMs: REPLAY_TIMEOUT_MS,
|
||||
|
||||
+18
-1
@@ -1271,7 +1271,15 @@ export interface BrowserTransformExecuteInput {
|
||||
packet: BrowserTransformPacket;
|
||||
}
|
||||
|
||||
export interface BrowserTransformValidationExecuteInput {
|
||||
validationId: string;
|
||||
direction: BrowserTransformDirectionName;
|
||||
packet: BrowserTransformPacket;
|
||||
}
|
||||
|
||||
export interface BrowserTransformExecution {
|
||||
explanation?: BrowserTransformExplanation;
|
||||
proofLevel?: 'structure' | 'exact' | 'execution-only';
|
||||
profileId: string;
|
||||
direction: BrowserTransformDirectionName;
|
||||
url: string;
|
||||
@@ -1732,6 +1740,13 @@ export interface PageContextDiff {
|
||||
|
||||
export type PageNodeAction = 'click' | 'focus' | 'scroll' | 'setValue';
|
||||
|
||||
export interface PageDialog {
|
||||
type: 'alert' | 'confirm' | 'prompt';
|
||||
message: string;
|
||||
decision: 'auto_dismissed' | 'auto_accepted' | 'auto_submitted';
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface PageNodeDetails extends PageNodeSummary {
|
||||
reference: PageNodeReference;
|
||||
connected: boolean;
|
||||
@@ -1741,8 +1756,10 @@ export interface PageNodeDetails extends PageNodeSummary {
|
||||
|
||||
export interface PageNodeActionResult {
|
||||
action: PageNodeAction;
|
||||
completedAt: number;
|
||||
status: 'dispatched';
|
||||
dispatchedAt: number;
|
||||
node: PageNodeDetails;
|
||||
dialogs?: PageDialog[];
|
||||
}
|
||||
|
||||
export interface PageEvalRequest {
|
||||
|
||||
Reference in New Issue
Block a user