mirror of
https://github.com/yaklang/yaklang-chrome-extension.git
synced 2026-09-26 13:11:53 +08:00
fix(capture): automate safe bidirectional gateways for users and agents
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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: '本次证据可用于分析,但不足以生成明文转换;继续使用当前页面,不要重新打开网站',
|
||||
|
||||
Reference in New Issue
Block a user