mirror of
https://github.com/yaklang/yaklang-chrome-extension.git
synced 2026-09-26 05:01:53 +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:
@@ -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',
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user