fix(capture): automate safe bidirectional gateways for users and agents

This commit is contained in:
go0p
2026-09-17 17:24:54 +08:00
parent 45f53604e8
commit 601f8acbcf
48 changed files with 2111 additions and 516 deletions
@@ -91,11 +91,15 @@ describe('page crypto adapters', () => {
const CBC = {};
const Pkcs7 = {};
const parsed: string[] = [];
const hexParsed: string[] = [];
const cryptoJs = {
AES: { encrypt() { return 'cipher'; } },
AES: { encrypt() { return 'cipher'; }, decrypt() { return 'plain'; } },
mode: { CBC },
pad: { Pkcs7 },
enc: { Base64: { parse(value: string) { parsed.push(value); return { wordArray: value }; } } },
enc: {
Base64: { parse(value: string) { parsed.push(value); return { wordArray: value }; } },
Hex: { parse(value: string) { hexParsed.push(value); return { hexWordArray: value }; } },
},
};
const scope = { window: { CryptoJS: cryptoJs } as unknown as Window } satisfies CryptoAdapterScope;
const encrypt = cryptoJsAdapter.discover(scope).find((item) => item.operation === 'AES.encrypt');
@@ -117,6 +121,28 @@ describe('page crypto adapters', () => {
]);
expect(plan?.adaptInput?.(new Uint8Array([4, 5, 6]))).toEqual({ wordArray: 'base64:4,5,6' });
expect(parsed).toEqual(['base64:4,5,6']);
const decrypt = cryptoJsAdapter.discover(scope).find((item) => item.operation === 'AES.decrypt');
const decryptPlan = decrypt?.describe(cryptoJs.AES, [
'cipher',
{ sigBytes: 16 },
{ mode: CBC, padding: Pkcs7, iv: { sigBytes: 16 } },
], toolkit());
expect(decryptPlan?.crypto.outputEncoding).toBe('hex');
expect(decryptPlan?.outputEncoding).toBe('hex');
expect(decryptPlan?.replayInputs?.map((input) => input.path)).toEqual([
'$input', '$input.key', '$input.iv',
]);
const replayArgs: unknown[] = ['old-cipher', { oldKey: true }, { mode: CBC, padding: Pkcs7, iv: { oldIv: true } }];
decryptPlan?.replayInputs?.[0].apply(replayArgs, 'new-cipher');
decryptPlan?.replayInputs?.[1].apply(replayArgs, '00112233');
decryptPlan?.replayInputs?.[2].apply(replayArgs, 'aabbccdd');
expect(replayArgs).toEqual([
'new-cipher',
{ hexWordArray: '00112233' },
{ mode: CBC, padding: Pkcs7, iv: { hexWordArray: 'aabbccdd' } },
]);
expect(hexParsed).toEqual(['00112233', 'aabbccdd']);
});
it('retains only bounded JSEncrypt receiver metadata', () => {
@@ -47,6 +47,13 @@ export interface CryptoAdapterInvocationPlan {
arguments: BrowserRecordingCallArgument[];
callableKind?: CallableOperationKind;
outputEncoding?: BrowserPageCallableValueEncoding;
replayInputs?: Array<{
path: string;
name: string;
role: BrowserRecordingCallArgument['role'];
originalInput: unknown;
apply(args: unknown[], value: unknown): void;
}>;
inputEvidence?(value: unknown): BrowserRecordingValueEvidence[];
outputEvidence?(value: unknown): BrowserRecordingValueEvidence[];
outputError?(value: unknown): string | undefined;
@@ -60,6 +60,8 @@ function describe(
const cryptoJs = (scope.window as unknown as { CryptoJS?: Record<string, unknown> }).CryptoJS || {};
const normalized = path.toLowerCase();
const encrypting = normalized.includes('encrypt');
const decrypting = normalized.includes('decrypt');
const outputEncoding = encrypting ? 'base64' : decrypting ? 'hex' : 'auto';
const options = normalized.includes('encrypt') || normalized.includes('decrypt')
? optionsMetadata(cryptoJs, args[2], toolkit)
: {};
@@ -68,6 +70,33 @@ function describe(
else if (normalized.includes('pbkdf2') || normalized.includes('evpkdf')) roles = ['data', 'salt', 'options'];
else if (normalized.includes('.encrypt') || normalized.includes('.decrypt')) roles = ['data', 'key', 'options'];
const callableKind = callableOperationKind(path);
const adaptData = (value: unknown) => {
const originalInput = args[0];
if (originalInput && typeof originalInput === 'object'
&& typeof (originalInput as { sigBytes?: unknown }).sigBytes === 'number') {
const bytes = toolkit.bytesForInput(value);
const encoder = (cryptoJs as { enc?: { Base64?: { parse?(input: string): unknown } } }).enc?.Base64;
if (bytes && typeof encoder?.parse === 'function') return encoder.parse(toolkit.bytesToBase64(bytes));
}
return toolkit.defaultAdaptInput(value, originalInput);
};
const adaptWordArray = (value: unknown, originalInput: unknown) => {
if (!originalInput || typeof originalInput !== 'object'
|| typeof (originalInput as { sigBytes?: unknown }).sigBytes !== 'number') {
return toolkit.defaultAdaptInput(value, originalInput);
}
const enc = (cryptoJs as {
enc?: {
Hex?: { parse?(input: string): unknown };
Base64?: { parse?(input: string): unknown };
};
}).enc;
if (typeof value === 'string' && /^[0-9a-f]+$/i.test(value) && value.length % 2 === 0
&& typeof enc?.Hex?.parse === 'function') return enc.Hex.parse(value);
const bytes = toolkit.bytesForInput(value);
if (bytes && typeof enc?.Base64?.parse === 'function') return enc.Base64.parse(toolkit.bytesToBase64(bytes));
return toolkit.defaultAdaptInput(value, originalInput);
};
return {
crypto: {
adapterId: cryptoJsManifest.id,
@@ -78,12 +107,31 @@ function describe(
mode: options.mode,
padding: options.padding,
inputEncoding: 'auto',
outputEncoding: encrypting ? 'base64' : 'auto',
outputEncoding,
state: { model: 'stateless', phase: 'one-shot' },
},
inputIndex: 0,
callableKind,
outputEncoding: encrypting ? 'base64' : 'auto',
outputEncoding,
replayInputs: decrypting ? [
{
path: '$input', name: 'data', role: 'data', originalInput: args[0],
apply: (nextArgs, value) => { nextArgs[0] = adaptData(value); },
},
{
path: '$input.key', name: 'key', role: 'key', originalInput: args[1],
apply: (nextArgs, value) => { nextArgs[1] = adaptWordArray(value, args[1]); },
},
{
path: '$input.iv', name: 'iv', role: 'iv', originalInput: ownValue(args[2], 'iv'),
apply: (nextArgs, value) => {
const options = nextArgs[2] && typeof nextArgs[2] === 'object'
? nextArgs[2] as Record<string, unknown>
: {};
nextArgs[2] = { ...options, iv: adaptWordArray(value, ownValue(args[2], 'iv')) };
},
},
] : undefined,
arguments: args.slice(0, 8).map((value, index) => toolkit.argument(
index,
roles[index] || 'unknown',
@@ -121,14 +169,7 @@ function describe(
return output.slice(0, 48);
},
adaptInput(value) {
const originalInput = args[0];
if (originalInput && typeof originalInput === 'object'
&& typeof (originalInput as { sigBytes?: unknown }).sigBytes === 'number') {
const bytes = toolkit.bytesForInput(value);
const encoder = (cryptoJs as { enc?: { Base64?: { parse?(input: string): unknown } } }).enc?.Base64;
if (bytes && typeof encoder?.parse === 'function') return encoder.parse(toolkit.bytesToBase64(bytes));
}
return toolkit.defaultAdaptInput(value, originalInput);
return adaptData(value);
},
};
}
@@ -53,6 +53,7 @@ describe('atomic page crypto inspection', () => {
}],
traces: [], links: [], callables: [], profileCandidates: [{
id: 'candidate-1', direction: 'request', summary: 'login request',
status: 'ready',
confidence: { score: 0.95, level: 'high' },
source: { eventId: 'event-1', callHandleId: 'handle-1' },
sources: [],
@@ -117,6 +118,35 @@ describe('atomic page crypto inspection', () => {
expect(fixture.context).toHaveBeenCalledWith({ includeDom: true }, { tabId: 7, frameId: 0 });
});
it('prepares a response-only protocol when no request transform was observed', async () => {
fixture.stopRecording.mockResolvedValueOnce({
status: { target: { tabId: 7, frameId: 0, documentId: 'doc-1' }, active: false, documentAvailable: true, count: 1, droppedCount: 0 },
events: [{
id: 'decrypt-1', sequence: 1, timestamp: 1, recordingId: 'recording-1', traceId: 'trace-1',
kind: 'crypto', operation: 'AES.decrypt', inputs: [], outputs: [], sensitiveCaptured: true,
}],
traces: [], links: [], callables: [], profileCandidates: [{
id: 'candidate-response', recordingId: 'recording-1', traceId: 'trace-1', direction: 'response',
status: 'ready', confidence: { score: 100, level: 'high' },
source: { eventId: 'decrypt-1', callHandleId: 'handle-1' }, sources: [],
request: { method: 'POST', url: 'https://example.test/api', bodyFormat: 'json', mappings: [] },
}],
});
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.gatewayPreparation).toMatchObject({
state: 'ready',
direction: 'response',
directions: { request: { status: 'absent' }, response: { candidateId: 'candidate-response', status: 'ready' } },
});
});
it('waits for a delayed request instead of treating an empty capture as idle', async () => {
vi.useFakeTimers();
const startedAt = Date.now();
+28 -5
View File
@@ -19,6 +19,7 @@ import {
restorePageDialogCapture,
} from '@/features/page-context/dialogs';
import { ExtensionError } from '@/shared/errors';
import { pairedBrowserTransformCandidate } from '@/features/browser-transform/profile-draft';
import type {
BrowserRecordingEvent,
BrowserRecordingSnapshot,
@@ -219,11 +220,23 @@ export async function inspectPageCryptoOperation(
if (!snapshot || !action) {
throw new ExtensionError('crypto_inspection_incomplete', '未能完整执行页面加解密检查');
}
await stageBrowserProfileEvidence(snapshot);
await stageBrowserProfileEvidence(snapshot, action.node.semanticKey);
const preparation = snapshot.profileCandidates
.filter((candidate) => candidate.direction === 'request' && [candidate.source, ...candidate.sources]
.filter((candidate) => [candidate.source, ...candidate.sources]
.some((source) => Boolean(source.callHandleId)))
.sort((left, right) => right.confidence.score - left.confidence.score)[0];
.sort((left, right) => Number(right.direction === 'request') - Number(left.direction === 'request')
|| right.confidence.score - left.confidence.score)[0];
const pairedPreparation = preparation
? pairedBrowserTransformCandidate(snapshot.profileCandidates, preparation, true)
: undefined;
const directions = [preparation, pairedPreparation].filter(
(candidate): candidate is NonNullable<typeof candidate> => Boolean(candidate),
);
const requestPreparation = directions.find((candidate) => candidate.direction === 'request');
const responsePreparation = directions.find((candidate) => candidate.direction === 'response');
const preparationReady = Boolean(preparation?.status === 'ready'
&& directions.every((candidate) => candidate.status === 'ready'
&& [candidate.source, ...candidate.sources].some((source) => Boolean(source.callHandleId))));
const evidence = summarizeCryptoInspection(snapshot, requests);
return {
version: 1,
@@ -243,17 +256,27 @@ export async function inspectPageCryptoOperation(
},
postAction,
gatewayPreparation: preparation ? {
state: 'ready',
state: preparationReady ? 'ready' : 'capture-required',
candidateId: preparation.id,
direction: preparation.direction,
confidence: preparation.confidence,
directions: {
request: requestPreparation
? { candidateId: requestPreparation.id, status: requestPreparation.status }
: { status: 'absent' },
response: responsePreparation
? { candidateId: responsePreparation.id, status: responsePreparation.status }
: { status: 'absent' },
},
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 底层能力',
next: preparationReady
? '需要明文 HTTP 测试时,直接调用 browser.transform.prepare;同一事务的请求与响应会编译进一个 Profile'
: '调用 browser.transform.prepare,插件将自动重触发本次操作、捕获缺失的业务方向并验证完整网关;不需要打开插件 UI',
} : {
state: 'unavailable',
next: '本次证据可用于分析,但不足以生成明文转换;继续使用当前页面,不要重新打开网站',