mirror of
https://github.com/yaklang/yaklang-chrome-extension.git
synced 2026-09-25 12:41:53 +08:00
Enhance architecture documentation and update project dependencies. Introduce new features for browser recording, page callables, and transform capabilities. Improve build scripts and permissions for better functionality.
This commit is contained in:
@@ -0,0 +1,372 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type {
|
||||
BrowserRecordingCallArgument,
|
||||
BrowserRecordingEvent,
|
||||
BrowserRecordingLink,
|
||||
} from '@/types/models';
|
||||
import { inferBrowserTransformProfiles } from './inference';
|
||||
import { buildRecordingLinks } from '@/features/browser-recording/timeline';
|
||||
|
||||
function event(overrides: Partial<BrowserRecordingEvent> & Pick<BrowserRecordingEvent, 'id' | 'sequence' | 'kind' | 'operation'>): BrowserRecordingEvent {
|
||||
return {
|
||||
timestamp: 1_000 + overrides.sequence,
|
||||
recordingId: 'recording-1',
|
||||
traceId: 'trace-1',
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
sensitiveCaptured: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function link(overrides: Pick<BrowserRecordingLink, 'id' | 'fromEventId' | 'fromPath' | 'toEventId' | 'toPath'>): BrowserRecordingLink {
|
||||
return { traceId: 'trace-1', kind: 'value', confidence: 'exact', ...overrides };
|
||||
}
|
||||
|
||||
const safeArguments: BrowserRecordingCallArgument[] = [
|
||||
{ index: 0, role: 'data', dataType: 'string', byteLength: 52, replaceable: true, retained: true },
|
||||
{ index: 1, role: 'key', dataType: 'Object', byteLength: 16, replaceable: false, retained: true },
|
||||
{
|
||||
index: 2,
|
||||
role: 'options',
|
||||
dataType: 'Object',
|
||||
replaceable: false,
|
||||
retained: true,
|
||||
summary: 'mode=CBC padding=Pkcs7 ivBytes=16',
|
||||
},
|
||||
];
|
||||
|
||||
const cryptoJsAES = {
|
||||
adapterId: 'cryptojs', providerKind: 'library', family: 'symmetric', operation: 'AES.encrypt', algorithm: 'AES.encrypt',
|
||||
} as const;
|
||||
const webCryptoAES = {
|
||||
adapterId: 'webcrypto', providerKind: 'native', family: 'symmetric', operation: 'encrypt', algorithm: 'AES-GCM',
|
||||
} as const;
|
||||
const cryptoJsHmac = {
|
||||
adapterId: 'cryptojs', providerKind: 'library', family: 'mac', operation: 'HmacSHA256', algorithm: 'HmacSHA256',
|
||||
} as const;
|
||||
|
||||
describe('browser profile inference', () => {
|
||||
it('turns a JSEncrypt RSA result mapped to a form field into a ready profile', () => {
|
||||
const rsa = event({
|
||||
id: 'rsa-1', sequence: 1, kind: 'crypto', operation: 'encrypt',
|
||||
crypto: {
|
||||
adapterId: 'jsencrypt', providerKind: 'library', family: 'asymmetric', operation: 'encrypt', algorithm: 'RSA',
|
||||
padding: 'PKCS1-v1_5', inputEncoding: 'utf8', outputEncoding: 'base64',
|
||||
key: { kind: 'public', bits: 1024, fingerprint: 'key-fingerprint' },
|
||||
},
|
||||
callHandleId: 'rsa-handle', callableCapable: true,
|
||||
arguments: [{ index: 0, role: 'data', dataType: 'string', byteLength: 44, replaceable: true, retained: true }],
|
||||
inputs: [{ path: '$input', fingerprint: 'plain-json', encoding: 'text', byteLength: 44 }],
|
||||
outputs: [{ path: '$output', fingerprint: 'rsa-cipher', encoding: 'text', byteLength: 172 }],
|
||||
});
|
||||
const request = event({
|
||||
id: 'request-rsa', sequence: 2, kind: 'fetch', operation: 'request', method: 'POST', url: 'https://example.test/encrypt/rsa.php',
|
||||
inputs: [{ path: '$body:form.data', fingerprint: 'rsa-cipher', encoding: 'text', byteLength: 172 }],
|
||||
});
|
||||
const [candidate] = inferBrowserTransformProfiles({
|
||||
target: { tabId: 7, frameId: 0 },
|
||||
events: [rsa, request],
|
||||
links: [link({ id: 'rsa-link', fromEventId: rsa.id, fromPath: '$output', toEventId: request.id, toPath: '$body:form.data' })],
|
||||
});
|
||||
|
||||
expect(candidate).toMatchObject({
|
||||
status: 'ready',
|
||||
request: {
|
||||
destination: 'body.data',
|
||||
serialization: 'form-field',
|
||||
mappings: [{ sourceEventId: 'rsa-1', destination: 'body.data', serialization: 'form-field' }],
|
||||
},
|
||||
source: {
|
||||
eventId: 'rsa-1',
|
||||
callHandleId: 'rsa-handle',
|
||||
crypto: { adapterId: 'jsencrypt', algorithm: 'RSA', padding: 'PKCS1-v1_5' },
|
||||
},
|
||||
confidence: { level: 'high', score: 100 },
|
||||
});
|
||||
expect(candidate.summary).toContain('JSEncrypt RSA');
|
||||
});
|
||||
|
||||
it('turns an exact CryptoJS-to-JSON-field link into a high-confidence capture candidate', () => {
|
||||
const crypto = event({
|
||||
id: 'crypto-1', sequence: 1, kind: 'crypto', operation: 'AES.encrypt', crypto: cryptoJsAES,
|
||||
callHandleId: 'handle-1', callableCapable: true, arguments: safeArguments,
|
||||
inputs: [{ path: '$input', fingerprint: 'plain', encoding: 'text', byteLength: 52 }],
|
||||
outputs: [{ path: '$output:string', fingerprint: 'cipher', encoding: 'text', byteLength: 88 }],
|
||||
});
|
||||
const request = event({
|
||||
id: 'request-1', sequence: 2, kind: 'fetch', operation: 'request', method: 'POST', url: 'https://example.test/api/login',
|
||||
inputs: [{ path: '$body:json.encryptedData', fingerprint: 'cipher', encoding: 'text', byteLength: 88 }],
|
||||
});
|
||||
const candidates = inferBrowserTransformProfiles({
|
||||
target: { tabId: 7, frameId: 0, documentId: 'document-1' },
|
||||
events: [crypto, request],
|
||||
links: [link({ id: 'link-1', fromEventId: crypto.id, fromPath: '$output:string', toEventId: request.id, toPath: '$body:json.encryptedData' })],
|
||||
});
|
||||
|
||||
expect(candidates).toHaveLength(1);
|
||||
expect(candidates[0]).toMatchObject({
|
||||
status: 'ready',
|
||||
request: { destination: 'body.encryptedData', serialization: 'json-field' },
|
||||
source: { eventId: 'crypto-1', callHandleId: 'handle-1', arguments: safeArguments },
|
||||
confidence: { level: 'high', score: 100 },
|
||||
});
|
||||
expect(candidates[0].evidence.some((item) => item.kind === 'exact-value' && item.strength === 'proven')).toBe(true);
|
||||
expect(candidates[0].aiContext.valuePolicy).toBe('metadata-only');
|
||||
});
|
||||
|
||||
it('follows a bounded exact-value chain through an intermediate encoder', () => {
|
||||
const crypto = event({
|
||||
id: 'crypto-1', sequence: 1, kind: 'crypto', operation: 'encrypt', crypto: webCryptoAES,
|
||||
callHandleId: 'handle-1', callableCapable: true, arguments: safeArguments,
|
||||
outputs: [{ path: '$output', fingerprint: 'raw-cipher', encoding: 'base64', byteLength: 64 }],
|
||||
});
|
||||
const encoder = event({
|
||||
id: 'encode-1', sequence: 2, kind: 'transform', operation: 'base64.encode',
|
||||
inputs: [{ path: '$input', fingerprint: 'raw-cipher', encoding: 'base64', byteLength: 64 }],
|
||||
outputs: [{ path: '$output', fingerprint: 'encoded-cipher', encoding: 'text', byteLength: 88 }],
|
||||
});
|
||||
const request = event({
|
||||
id: 'request-1', sequence: 3, kind: 'xhr', operation: 'request', method: 'POST', url: 'https://example.test/api/login',
|
||||
inputs: [{ path: '$headers.x-signature', fingerprint: 'encoded-cipher', encoding: 'text', byteLength: 88 }],
|
||||
});
|
||||
const candidates = inferBrowserTransformProfiles({
|
||||
target: { tabId: 7, frameId: 0 },
|
||||
events: [crypto, encoder, request],
|
||||
links: [
|
||||
link({ id: 'link-1', fromEventId: crypto.id, fromPath: '$output', toEventId: encoder.id, toPath: '$input' }),
|
||||
link({ id: 'link-2', fromEventId: encoder.id, fromPath: '$output', toEventId: request.id, toPath: '$headers.x-signature' }),
|
||||
],
|
||||
});
|
||||
|
||||
const cryptoCandidate = candidates.find((item) => item.source.eventId === crypto.id);
|
||||
expect(candidates).toHaveLength(1);
|
||||
expect(cryptoCandidate?.request.destination).toBe('header.x-signature');
|
||||
expect(cryptoCandidate?.evidence.filter((item) => item.kind === 'exact-value')).toHaveLength(2);
|
||||
expect(cryptoCandidate?.flow).toContain('1 个中间转换');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['$body:form.encryptedData', 'body.encryptedData'],
|
||||
['$query.signature', 'query.signature'],
|
||||
])('maps generic serialized request evidence %s to %s', (toPath, destination) => {
|
||||
const crypto = event({
|
||||
id: 'crypto-1', sequence: 1, kind: 'crypto', operation: 'AES.encrypt', crypto: cryptoJsAES,
|
||||
callHandleId: 'handle-1', callableCapable: true, arguments: safeArguments,
|
||||
outputs: [{ path: '$output:string', fingerprint: 'cipher', encoding: 'text', byteLength: 88 }],
|
||||
});
|
||||
const request = event({
|
||||
id: 'request-1', sequence: 2, kind: 'fetch', operation: 'request', method: 'POST',
|
||||
url: 'https://example.test/session',
|
||||
inputs: [{ path: toPath, fingerprint: 'cipher', encoding: 'text', byteLength: 88 }],
|
||||
});
|
||||
const [candidate] = inferBrowserTransformProfiles({
|
||||
target: { tabId: 7, frameId: 0 },
|
||||
events: [crypto, request],
|
||||
links: [link({ id: 'link-1', fromEventId: crypto.id, fromPath: '$output:string', toEventId: request.id, toPath })],
|
||||
});
|
||||
|
||||
expect(candidate.request.destination).toBe(destination);
|
||||
expect(candidate.confidence.level).toBe('high');
|
||||
expect(candidate.request.serialization).toBe(toPath.startsWith('$body:form.') ? 'form-field' : 'query');
|
||||
});
|
||||
|
||||
it('keeps a same-trace temporal guess low-confidence and never includes captured values in AI context', () => {
|
||||
const crypto = event({
|
||||
id: 'crypto-1', sequence: 1, kind: 'crypto', operation: 'HmacSHA256', crypto: cryptoJsHmac,
|
||||
inputPreview: 'plain-password', outputPreview: 'secret-signature', arguments: safeArguments,
|
||||
});
|
||||
const request = event({
|
||||
id: 'request-1', sequence: 2, kind: 'fetch', operation: 'request', method: 'POST',
|
||||
url: 'https://example.test/api/login?token=secret-query-value&mode=fast#private-fragment',
|
||||
inputPreview: '{"password":"plain-password"}',
|
||||
});
|
||||
const [candidate] = inferBrowserTransformProfiles({ target: { tabId: 7, frameId: 0 }, events: [crypto, request], links: [] });
|
||||
|
||||
expect(candidate.status).toBe('capture-required');
|
||||
expect(candidate.confidence.level).toBe('low');
|
||||
expect(JSON.stringify(candidate)).not.toContain('plain-password');
|
||||
expect(JSON.stringify(candidate)).not.toContain('secret-signature');
|
||||
expect(JSON.stringify(candidate.aiContext)).not.toContain('secret-query-value');
|
||||
expect(JSON.stringify(candidate.aiContext)).not.toContain('private-fragment');
|
||||
expect(candidate.aiContext.request.url).toBe('https://example.test/api/login?mode&token');
|
||||
expect(candidate.summary).toContain('可继续捕获完整页面业务封装');
|
||||
expect(candidate.missing[0].label).not.toMatch(/更短|重新录制一次|操作太长/);
|
||||
});
|
||||
|
||||
it('continues from an unknown request boundary even when no known crypto library is visible', () => {
|
||||
const request = event({
|
||||
id: 'opaque-request', sequence: 1, kind: 'fetch', operation: 'request', method: 'POST',
|
||||
url: 'https://example.test/opaque', stack: 'at pack (https://example.test/chunk-a.js:1:42)',
|
||||
});
|
||||
const [candidate] = inferBrowserTransformProfiles({
|
||||
target: { tabId: 7, frameId: 0 }, events: [request], links: [],
|
||||
});
|
||||
|
||||
expect(candidate).toMatchObject({ status: 'capture-required', source: { operation: 'unknown-business-envelope' } });
|
||||
expect(candidate.summary).toContain('算法或库未知不影响继续捕获');
|
||||
expect(candidate.aiContext.requiredDecision).toBe('capture-business-callable');
|
||||
expect(candidate.capturePlan).toMatchObject({ matcherEventId: request.id, sourceCount: 1 });
|
||||
});
|
||||
|
||||
it('treats a Worker round trip as correlated evidence rather than an exact value proof', () => {
|
||||
const crypto = event({
|
||||
id: 'crypto', sequence: 1, kind: 'crypto', operation: 'encrypt', crypto: webCryptoAES,
|
||||
outputs: [{ path: '$output', fingerprint: 'plain-to-worker', encoding: 'base64', byteLength: 32 }],
|
||||
});
|
||||
const send = event({
|
||||
id: 'worker-send', sequence: 2, kind: 'worker', operation: 'worker.postMessage', direction: 'send', channelId: 'channel-1',
|
||||
inputs: [{ path: '$message', fingerprint: 'plain-to-worker', encoding: 'base64', byteLength: 32 }],
|
||||
});
|
||||
const receive = event({
|
||||
id: 'worker-receive', sequence: 3, kind: 'worker', operation: 'worker.message', direction: 'receive', channelId: 'channel-1',
|
||||
outputs: [{ path: '$message', fingerprint: 'worker-result', encoding: 'text', byteLength: 64 }],
|
||||
});
|
||||
const request = event({
|
||||
id: 'request', sequence: 4, kind: 'fetch', operation: 'request', method: 'POST', url: 'https://example.test/submit',
|
||||
inputs: [{ path: '$body:json.payload', fingerprint: 'worker-result', encoding: 'text', byteLength: 64 }],
|
||||
});
|
||||
const links: BrowserRecordingLink[] = [
|
||||
link({ id: 'value-in', fromEventId: crypto.id, fromPath: '$output', toEventId: send.id, toPath: '$message' }),
|
||||
{ id: 'channel', traceId: 'trace-1', kind: 'channel', confidence: 'correlated', fromEventId: send.id, fromPath: '$message', toEventId: receive.id, toPath: '$message' },
|
||||
link({ id: 'value-out', fromEventId: receive.id, fromPath: '$message', toEventId: request.id, toPath: '$body:json.payload' }),
|
||||
];
|
||||
const [candidate] = inferBrowserTransformProfiles({
|
||||
target: { tabId: 7, frameId: 0 }, events: [crypto, send, receive, request], links,
|
||||
});
|
||||
|
||||
expect(candidate.status).toBe('capture-required');
|
||||
expect(candidate.request.destination).toBe('body.payload');
|
||||
expect(candidate.evidence.some((item) => item.kind === 'message-boundary' && item.strength === 'supported')).toBe(true);
|
||||
});
|
||||
|
||||
it('groups hybrid AES and RSA outputs into one request graph and refuses unsafe independent replay', () => {
|
||||
const sources = [
|
||||
event({
|
||||
id: 'aes-data', sequence: 1, kind: 'crypto', operation: 'AES.encrypt', crypto: cryptoJsAES,
|
||||
callHandleId: 'aes-handle', callableCapable: true, arguments: safeArguments,
|
||||
stack: 'at aes (https://example.test/vendor/aes.js:1:1)\n at _0xPacket (https://example.test/app.js:40:2)\n at onclick (https://example.test/app.js:90:1)',
|
||||
outputs: [{ path: '$output:string', fingerprint: 'cipher-data', encoding: 'text', byteLength: 88 }],
|
||||
}),
|
||||
event({
|
||||
id: 'rsa-key', sequence: 2, kind: 'crypto', operation: 'encrypt',
|
||||
crypto: { adapterId: 'jsencrypt', providerKind: 'library', family: 'asymmetric', operation: 'encrypt', algorithm: 'RSA' },
|
||||
callHandleId: 'rsa-key-handle', callableCapable: true, arguments: safeArguments.slice(0, 1),
|
||||
stack: 'at rsa (https://example.test/vendor/rsa.js:1:1)\n at _0xPacket (https://example.test/app.js:51:2)\n at onclick (https://example.test/app.js:90:1)',
|
||||
outputs: [{ path: '$output', fingerprint: 'cipher-key', encoding: 'text', byteLength: 172 }],
|
||||
}),
|
||||
event({
|
||||
id: 'rsa-iv', sequence: 3, kind: 'crypto', operation: 'encrypt',
|
||||
crypto: { adapterId: 'jsencrypt', providerKind: 'library', family: 'asymmetric', operation: 'encrypt', algorithm: 'RSA' },
|
||||
callHandleId: 'rsa-iv-handle', callableCapable: true, arguments: safeArguments.slice(0, 1),
|
||||
stack: 'at rsa (https://example.test/vendor/rsa.js:8:1)\n at _0xPacket (https://example.test/app.js:58:2)\n at onclick (https://example.test/app.js:90:1)',
|
||||
outputs: [{ path: '$output', fingerprint: 'cipher-iv', encoding: 'text', byteLength: 172 }],
|
||||
}),
|
||||
];
|
||||
const request = event({
|
||||
id: 'hybrid-request', sequence: 4, kind: 'fetch', operation: 'request', method: 'POST', url: 'https://example.test/encrypt/aesrsa.php',
|
||||
inputs: [
|
||||
{ path: '$body:json.encryptedData', fingerprint: 'cipher-data', encoding: 'text', byteLength: 88 },
|
||||
{ path: '$body:json.encryptedKey', fingerprint: 'cipher-key', encoding: 'text', byteLength: 172 },
|
||||
{ path: '$body:json.encryptedIv', fingerprint: 'cipher-iv', encoding: 'text', byteLength: 172 },
|
||||
],
|
||||
});
|
||||
const links = sources.map((source, index) => link({
|
||||
id: `hybrid-link-${index}`,
|
||||
fromEventId: source.id,
|
||||
fromPath: source.outputs[0].path,
|
||||
toEventId: request.id,
|
||||
toPath: request.inputs[index].path,
|
||||
}));
|
||||
|
||||
const [candidate] = inferBrowserTransformProfiles({ target: { tabId: 7, frameId: 0 }, events: [...sources, request], links });
|
||||
expect(candidate.sources).toHaveLength(3);
|
||||
expect(candidate.request.mappings.map((item) => item.destination)).toEqual([
|
||||
'body.encryptedData', 'body.encryptedKey', 'body.encryptedIv',
|
||||
]);
|
||||
expect(candidate.status).toBe('capture-required');
|
||||
expect(candidate.summary).toContain('3 个密码调用');
|
||||
expect(candidate.missing[0].label).toContain('随机 Key、IV、Nonce');
|
||||
expect(candidate.capturePlan).toMatchObject({
|
||||
matcherEventId: sources[0].id,
|
||||
sourceCount: 3,
|
||||
expectedDestinations: ['body.encryptedData', 'body.encryptedKey', 'body.encryptedIv'],
|
||||
});
|
||||
expect(candidate.capturePlan?.frameHints[0]).toMatchObject({ functionName: '_0xPacket', support: 3 });
|
||||
});
|
||||
|
||||
it('keeps a stateful signature session as one request source and exposes its ordered stages', () => {
|
||||
const crypto = (operation: string, phase: 'create' | 'update' | 'final') => ({
|
||||
adapterId: 'jsrsasign', providerKind: 'library' as const, family: 'signature' as const, operation,
|
||||
algorithm: 'SHA256withRSA',
|
||||
state: { model: 'session' as const, correlationId: 'signature-session-1', phase },
|
||||
});
|
||||
const create = event({
|
||||
id: 'signature-create', sequence: 1, kind: 'crypto', operation: 'Signature.create', crypto: crypto('Signature.create', 'create'),
|
||||
});
|
||||
const update = event({
|
||||
id: 'signature-update', sequence: 2, kind: 'crypto', operation: 'Signature.updateString', crypto: crypto('Signature.updateString', 'update'),
|
||||
});
|
||||
const final = event({
|
||||
id: 'signature-final', sequence: 3, kind: 'crypto', operation: 'Signature.sign', crypto: crypto('Signature.sign', 'final'),
|
||||
outputs: [{ path: '$output', fingerprint: 'signature', encoding: 'hex', byteLength: 64 }],
|
||||
});
|
||||
const request = event({
|
||||
id: 'signed-request', sequence: 4, kind: 'fetch', operation: 'request', method: 'POST', url: 'https://example.test/api/signed',
|
||||
inputs: [{ path: '$headers.x-signature', fingerprint: 'signature', encoding: 'hex', byteLength: 64 }],
|
||||
});
|
||||
const links: BrowserRecordingLink[] = [
|
||||
{ id: 'state-create-update', traceId: 'trace-1', kind: 'state', confidence: 'correlated', fromEventId: create.id, fromPath: '$state.create', toEventId: update.id, toPath: '$state.update' },
|
||||
{ id: 'state-update-final', traceId: 'trace-1', kind: 'state', confidence: 'correlated', fromEventId: update.id, fromPath: '$state.update', toEventId: final.id, toPath: '$state.final' },
|
||||
link({ id: 'signature-request', fromEventId: final.id, fromPath: '$output', toEventId: request.id, toPath: '$headers.x-signature' }),
|
||||
];
|
||||
|
||||
const [candidate] = inferBrowserTransformProfiles({
|
||||
target: { tabId: 7, frameId: 0 }, events: [create, update, final, request], links,
|
||||
});
|
||||
expect(candidate.sources).toHaveLength(1);
|
||||
expect(candidate.source.eventId).toBe(final.id);
|
||||
expect(candidate.request.destination).toBe('header.x-signature');
|
||||
expect(candidate.evidence).toContainEqual(expect.objectContaining({
|
||||
kind: 'state-sequence', eventIds: [create.id, update.id, final.id],
|
||||
}));
|
||||
expect(candidate.capturePlan).toMatchObject({ sourceCount: 3 });
|
||||
});
|
||||
|
||||
it('traces canonical JSON through a signature and Axios into a request header', () => {
|
||||
const canonical = event({
|
||||
id: 'canonical-json', sequence: 1, kind: 'transform', operation: 'JSON.stringify',
|
||||
transform: { category: 'serializer', provider: 'native', phase: 'output' },
|
||||
inputs: [{ path: '$input.account', fingerprint: 'account', encoding: 'text', byteLength: 5 }],
|
||||
outputs: [{ path: '$output', fingerprint: 'canonical', encoding: 'text', byteLength: 42 }],
|
||||
});
|
||||
const signature = event({
|
||||
id: 'signature', sequence: 2, kind: 'crypto', operation: 'HmacSHA256', crypto: cryptoJsHmac,
|
||||
callHandleId: 'signature-handle', callableCapable: true, arguments: safeArguments,
|
||||
inputs: [{ path: '$input', fingerprint: 'canonical', encoding: 'text', byteLength: 42 }],
|
||||
outputs: [{ path: '$output', fingerprint: 'signed', encoding: 'hex', byteLength: 64 }],
|
||||
});
|
||||
const axios = event({
|
||||
id: 'axios', sequence: 3, kind: 'transform', operation: 'axios.request',
|
||||
transform: { category: 'request-builder', provider: 'axios', phase: 'boundary' },
|
||||
inputs: [{ path: '$headers.X-Signature', fingerprint: 'signed', encoding: 'hex', byteLength: 64 }],
|
||||
outputs: [{ path: '$headers.X-Signature', fingerprint: 'signed', encoding: 'hex', byteLength: 64 }],
|
||||
});
|
||||
const request = event({
|
||||
id: 'request', sequence: 4, kind: 'xhr', operation: 'request', method: 'POST', url: 'https://example.test/api/order',
|
||||
inputs: [{ path: '$headers.x-signature', fingerprint: 'signed', encoding: 'hex', byteLength: 64 }],
|
||||
});
|
||||
const events = [canonical, signature, axios, request];
|
||||
const [candidate] = inferBrowserTransformProfiles({
|
||||
target: { tabId: 7, frameId: 0 }, events, links: buildRecordingLinks(events),
|
||||
});
|
||||
|
||||
expect(candidate.sources).toHaveLength(1);
|
||||
expect(candidate.request).toMatchObject({ destination: 'header.x-signature', serialization: 'header' });
|
||||
expect(candidate.evidence).toContainEqual(expect.objectContaining({
|
||||
kind: 'transform-lineage', strength: 'proven', eventIds: [canonical.id, signature.id],
|
||||
}));
|
||||
expect(candidate.flow).toContain('1 个输入准备步骤');
|
||||
expect(candidate.flow).toContain('1 个中间转换');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,622 @@
|
||||
import type {
|
||||
BrowserProfileInferenceCandidate,
|
||||
BrowserProfileInferenceEvidence,
|
||||
BrowserProfileInferenceMissingStep,
|
||||
BrowserProfileInferenceSerialization,
|
||||
BrowserRecordingEvent,
|
||||
BrowserRecordingLink,
|
||||
BrowserTarget,
|
||||
} from '@/types/models';
|
||||
import { cryptoEventLabel, isForwardCryptoEvent } from '@/features/browser-crypto/model';
|
||||
import { inferBusinessFrameHints } from './stack-hints';
|
||||
|
||||
const MAX_LINK_DEPTH = 8;
|
||||
const MAX_CANDIDATES = 16;
|
||||
|
||||
export interface BrowserProfileInferenceInput {
|
||||
target: BrowserTarget;
|
||||
events: BrowserRecordingEvent[];
|
||||
links: BrowserRecordingLink[];
|
||||
}
|
||||
|
||||
interface LinkedSource {
|
||||
event: BrowserRecordingEvent;
|
||||
links: BrowserRecordingLink[];
|
||||
stateLinks: BrowserRecordingLink[];
|
||||
stateEvents: BrowserRecordingEvent[];
|
||||
inputLinks: BrowserRecordingLink[];
|
||||
inputEvents: BrowserRecordingEvent[];
|
||||
}
|
||||
|
||||
function isRequestEvent(event: BrowserRecordingEvent): boolean {
|
||||
return ['fetch', 'xhr', 'form', 'beacon'].includes(event.kind) && event.operation === 'request';
|
||||
}
|
||||
|
||||
function isCandidateSource(event: BrowserRecordingEvent): boolean {
|
||||
return isForwardCryptoEvent(event);
|
||||
}
|
||||
|
||||
function requestMapping(path?: string): { destination?: string; serialization?: BrowserProfileInferenceSerialization } {
|
||||
if (!path) return {};
|
||||
if (path === '$body' || path === '$body:json') return { destination: 'body', serialization: 'raw-body' };
|
||||
if (path.startsWith('$body:json.')) {
|
||||
return { destination: `body.${path.slice('$body:json.'.length)}`, serialization: 'json-field' };
|
||||
}
|
||||
if (path.startsWith('$body:form.')) {
|
||||
return { destination: `body.${path.slice('$body:form.'.length)}`, serialization: 'form-field' };
|
||||
}
|
||||
if (path.startsWith('$body.')) return { destination: `body.${path.slice('$body.'.length)}`, serialization: 'json-field' };
|
||||
if (path.startsWith('$headers.')) return { destination: `header.${path.slice('$headers.'.length)}`, serialization: 'header' };
|
||||
if (path.startsWith('$query.')) return { destination: `query.${path.slice('$query.'.length)}`, serialization: 'query' };
|
||||
return {};
|
||||
}
|
||||
|
||||
function requestLabel(event: BrowserRecordingEvent): string {
|
||||
const method = event.method || 'GET';
|
||||
if (!event.url) return method;
|
||||
try {
|
||||
return `${method} ${new URL(event.url, 'https://recording.invalid').pathname || '/'}`;
|
||||
} catch {
|
||||
return `${method} ${event.url}`;
|
||||
}
|
||||
}
|
||||
|
||||
function safeUrlMetadata(value?: string): string | undefined {
|
||||
if (!value) return undefined;
|
||||
try {
|
||||
const parsed = new URL(value, 'https://recording.invalid');
|
||||
const relative = !/^[a-z][a-z\d+.-]*:\/\//i.test(value);
|
||||
const queryKeys = [...new Set([...parsed.searchParams.keys()])].sort();
|
||||
const query = queryKeys.length ? `?${queryKeys.map(encodeURIComponent).join('&')}` : '';
|
||||
return relative ? `${parsed.pathname}${query}` : `${parsed.origin}${parsed.pathname}${query}`;
|
||||
} catch {
|
||||
return value.split(/[?#]/, 1)[0].slice(0, 2_048);
|
||||
}
|
||||
}
|
||||
|
||||
function sourceLabel(event: BrowserRecordingEvent): string {
|
||||
return event.kind === 'crypto' ? cryptoEventLabel(event) : event.operation;
|
||||
}
|
||||
|
||||
function linkedSources(
|
||||
request: BrowserRecordingEvent,
|
||||
eventsById: Map<string, BrowserRecordingEvent>,
|
||||
incoming: Map<string, BrowserRecordingLink[]>,
|
||||
): LinkedSource[] {
|
||||
const output = new Map<string, LinkedSource>();
|
||||
const queue: Array<{ eventId: string; links: BrowserRecordingLink[]; depth: number }> = [
|
||||
{ eventId: request.id, links: [], depth: 0 },
|
||||
];
|
||||
const visitedDepth = new Map<string, number>([[request.id, 0]]);
|
||||
while (queue.length) {
|
||||
const current = queue.shift()!;
|
||||
if (current.depth >= MAX_LINK_DEPTH) continue;
|
||||
for (const link of incoming.get(current.eventId) || []) {
|
||||
if (link.kind === 'state') continue;
|
||||
const source = eventsById.get(link.fromEventId);
|
||||
if (!source || source.traceId !== request.traceId || source.sequence >= request.sequence) continue;
|
||||
const chain = [link, ...current.links];
|
||||
if (isCandidateSource(source)) {
|
||||
const previous = output.get(source.id);
|
||||
if (!previous || chain.length < previous.links.length) {
|
||||
output.set(source.id, {
|
||||
event: source,
|
||||
links: chain,
|
||||
...stateSequence(source, eventsById, incoming),
|
||||
...inputLineage(source, eventsById, incoming),
|
||||
});
|
||||
}
|
||||
}
|
||||
const depth = current.depth + 1;
|
||||
if ((visitedDepth.get(source.id) ?? Number.POSITIVE_INFINITY) <= depth) continue;
|
||||
visitedDepth.set(source.id, depth);
|
||||
queue.push({ eventId: source.id, links: chain, depth });
|
||||
}
|
||||
}
|
||||
return [...output.values()];
|
||||
}
|
||||
|
||||
function inputLineage(
|
||||
event: BrowserRecordingEvent,
|
||||
eventsById: Map<string, BrowserRecordingEvent>,
|
||||
incoming: Map<string, BrowserRecordingLink[]>,
|
||||
): Pick<LinkedSource, 'inputLinks' | 'inputEvents'> {
|
||||
const linksById = new Map<string, BrowserRecordingLink>();
|
||||
const events = new Map<string, BrowserRecordingEvent>();
|
||||
const queue: Array<{ event: BrowserRecordingEvent; depth: number }> = [{ event, depth: 0 }];
|
||||
const visited = new Set<string>([event.id]);
|
||||
while (queue.length) {
|
||||
const current = queue.shift()!;
|
||||
if (current.depth >= MAX_LINK_DEPTH) continue;
|
||||
for (const link of incoming.get(current.event.id) || []) {
|
||||
if (link.kind !== 'value' || link.confidence !== 'exact') continue;
|
||||
const source = eventsById.get(link.fromEventId);
|
||||
if (!source || source.traceId !== event.traceId || source.kind !== 'transform' || visited.has(source.id)) continue;
|
||||
visited.add(source.id);
|
||||
linksById.set(link.id, link);
|
||||
events.set(source.id, source);
|
||||
queue.push({ event: source, depth: current.depth + 1 });
|
||||
}
|
||||
}
|
||||
return {
|
||||
inputLinks: [...linksById.values()].sort((left, right) => (
|
||||
(eventsById.get(left.fromEventId)?.sequence || 0) - (eventsById.get(right.fromEventId)?.sequence || 0)
|
||||
)),
|
||||
inputEvents: [...events.values()].sort((left, right) => left.sequence - right.sequence),
|
||||
};
|
||||
}
|
||||
|
||||
function stateSequence(
|
||||
event: BrowserRecordingEvent,
|
||||
eventsById: Map<string, BrowserRecordingEvent>,
|
||||
incoming: Map<string, BrowserRecordingLink[]>,
|
||||
): Pick<LinkedSource, 'stateLinks' | 'stateEvents'> {
|
||||
const links: BrowserRecordingLink[] = [];
|
||||
const events: BrowserRecordingEvent[] = [event];
|
||||
const visited = new Set<string>([event.id]);
|
||||
let current = event;
|
||||
while (links.length < MAX_LINK_DEPTH) {
|
||||
const link = (incoming.get(current.id) || [])
|
||||
.filter((item) => item.kind === 'state')
|
||||
.sort((left, right) => {
|
||||
const leftSequence = eventsById.get(left.fromEventId)?.sequence ?? -1;
|
||||
const rightSequence = eventsById.get(right.fromEventId)?.sequence ?? -1;
|
||||
return rightSequence - leftSequence;
|
||||
})[0];
|
||||
if (!link) break;
|
||||
const source = eventsById.get(link.fromEventId);
|
||||
if (!source || source.traceId !== event.traceId || visited.has(source.id)) break;
|
||||
visited.add(source.id);
|
||||
links.unshift(link);
|
||||
events.unshift(source);
|
||||
current = source;
|
||||
}
|
||||
return { stateLinks: links, stateEvents: events };
|
||||
}
|
||||
|
||||
function temporalSource(
|
||||
request: BrowserRecordingEvent,
|
||||
events: BrowserRecordingEvent[],
|
||||
eventsById: Map<string, BrowserRecordingEvent>,
|
||||
incoming: Map<string, BrowserRecordingLink[]>,
|
||||
): LinkedSource | undefined {
|
||||
const source = events
|
||||
.filter((event) => event.traceId === request.traceId && event.sequence < request.sequence && isCandidateSource(event))
|
||||
.sort((left, right) => right.sequence - left.sequence)[0];
|
||||
return source ? {
|
||||
event: source,
|
||||
links: [],
|
||||
...stateSequence(source, eventsById, incoming),
|
||||
...inputLineage(source, eventsById, incoming),
|
||||
} : undefined;
|
||||
}
|
||||
|
||||
function confidenceLevel(score: number): 'high' | 'medium' | 'low' {
|
||||
if (score >= 80) return 'high';
|
||||
if (score >= 55) return 'medium';
|
||||
return 'low';
|
||||
}
|
||||
|
||||
function capturePlan(
|
||||
matcherEventId: string,
|
||||
events: BrowserRecordingEvent[],
|
||||
expectedDestinations: Array<string | undefined>,
|
||||
) {
|
||||
return {
|
||||
matcherEventId,
|
||||
frameHints: inferBusinessFrameHints(events),
|
||||
expectedDestinations: expectedDestinations.filter((item): item is string => Boolean(item)),
|
||||
sourceCount: events.length,
|
||||
};
|
||||
}
|
||||
|
||||
function buildCandidate(
|
||||
target: BrowserTarget,
|
||||
request: BrowserRecordingEvent,
|
||||
source: LinkedSource,
|
||||
): BrowserProfileInferenceCandidate {
|
||||
const exact = source.links.length > 0 && source.links.every((link) => link.confidence === 'exact');
|
||||
const finalLink = source.links.at(-1);
|
||||
const { destination, serialization } = requestMapping(finalLink?.toPath);
|
||||
const hasCallable = Boolean(source.event.callHandleId && source.event.callableCapable);
|
||||
const replayReady = hasCallable && Boolean(destination) && source.links.length === 1;
|
||||
const argumentRoles = source.event.arguments || [];
|
||||
const evidence: BrowserProfileInferenceEvidence[] = [{
|
||||
id: `evidence-request-${request.id}`,
|
||||
kind: 'request-boundary',
|
||||
strength: 'proven',
|
||||
label: `请求边界:${requestLabel(request)}`,
|
||||
eventIds: [request.id],
|
||||
toPath: finalLink?.toPath,
|
||||
}];
|
||||
source.links.forEach((link, index) => evidence.push({
|
||||
id: `evidence-link-${link.id || `${source.event.id}-${request.id}-${index}`}`,
|
||||
kind: link.confidence === 'exact' ? 'exact-value' : 'message-boundary',
|
||||
strength: link.confidence === 'exact' ? 'proven' : 'supported',
|
||||
label: link.confidence === 'correlated'
|
||||
? '同一 Worker / MessagePort 通道的发送与接收已关联'
|
||||
: index === source.links.length - 1 && destination
|
||||
? `输出指纹精确进入 ${destination}`
|
||||
: `中间值指纹精确匹配 ${link.fromPath} -> ${link.toPath}`,
|
||||
eventIds: [link.fromEventId, link.toEventId],
|
||||
fromPath: link.fromPath,
|
||||
toPath: link.toPath,
|
||||
}));
|
||||
if (source.stateLinks.length) {
|
||||
const phases = source.stateEvents
|
||||
.map((event) => event.crypto?.state?.phase)
|
||||
.filter((phase): phase is NonNullable<NonNullable<BrowserRecordingEvent['crypto']>['state']>['phase'] => Boolean(phase));
|
||||
evidence.push({
|
||||
id: `evidence-state-${source.event.id}`,
|
||||
kind: 'state-sequence',
|
||||
strength: 'supported',
|
||||
label: `同一密码会话已关联 ${phases.join(' -> ') || `${source.stateEvents.length} 个阶段`}`,
|
||||
eventIds: source.stateEvents.map((event) => event.id),
|
||||
fromPath: source.stateLinks[0]?.fromPath,
|
||||
toPath: source.stateLinks.at(-1)?.toPath,
|
||||
});
|
||||
}
|
||||
source.inputLinks.forEach((link, index) => {
|
||||
const transform = source.inputEvents.find((event) => event.id === link.fromEventId);
|
||||
const category = transform?.transform?.category;
|
||||
const label = category === 'canonicalization'
|
||||
? `已关联规范化步骤 ${transform?.operation || link.fromPath} 与密码调用输入`
|
||||
: category === 'request-builder'
|
||||
? `已关联请求准备步骤 ${transform?.operation || link.fromPath} 与密码调用输入`
|
||||
: `已关联序列化步骤 ${transform?.operation || link.fromPath} 与密码调用输入`;
|
||||
evidence.push({
|
||||
id: `evidence-input-transform-${link.id || `${source.event.id}-${index}`}`,
|
||||
kind: 'transform-lineage',
|
||||
strength: 'proven',
|
||||
label,
|
||||
eventIds: [link.fromEventId, link.toEventId],
|
||||
fromPath: link.fromPath,
|
||||
toPath: link.toPath,
|
||||
});
|
||||
});
|
||||
evidence.push({
|
||||
id: `evidence-order-${source.event.id}-${request.id}`,
|
||||
kind: 'trace-order',
|
||||
strength: 'supported',
|
||||
label: '加密调用与请求位于同一业务 Trace,且调用发生在请求之前',
|
||||
eventIds: [source.event.id, request.id],
|
||||
});
|
||||
if (hasCallable) evidence.push({
|
||||
id: `evidence-callable-${source.event.id}`,
|
||||
kind: 'callable',
|
||||
strength: 'proven',
|
||||
label: '页面仍保留本次调用的原函数、receiver 与固定参数模板',
|
||||
eventIds: [source.event.id],
|
||||
});
|
||||
|
||||
let score = 20;
|
||||
if (exact) score += 40;
|
||||
if (destination) score += 10;
|
||||
if (hasCallable) score += 15;
|
||||
if (argumentRoles.length) score += 10;
|
||||
score += 5;
|
||||
score = Math.min(100, score);
|
||||
|
||||
const missing: BrowserProfileInferenceMissingStep[] = [];
|
||||
let status: BrowserProfileInferenceCandidate['status'];
|
||||
if (!exact || !destination) {
|
||||
status = 'capture-required';
|
||||
missing.push({
|
||||
kind: 'business-callable',
|
||||
label: source.links.some((link) => link.confidence === 'correlated')
|
||||
? '已关联页面与 Worker 消息链;继续捕获请求前的上层业务函数,即可保留 Worker 内部状态与完整报文封装'
|
||||
: '字段级值链尚不完整;继续捕获请求前的上层业务函数,不需要重新录制更长的操作',
|
||||
action: 'capture-business-function',
|
||||
});
|
||||
} else if (replayReady) {
|
||||
status = 'ready';
|
||||
} else if (source.event.kind === 'crypto') {
|
||||
status = 'capture-required';
|
||||
missing.push({
|
||||
kind: 'business-callable',
|
||||
label: '已定位低层加密调用;还需捕获上层业务函数,才能保留序列化、动态参数与完整报文封装',
|
||||
action: 'capture-business-function',
|
||||
});
|
||||
} else {
|
||||
status = 'mapping-required';
|
||||
missing.push({
|
||||
kind: 'input-mapping',
|
||||
label: '需要确认明文输入在逻辑请求中的来源',
|
||||
action: 'select-input',
|
||||
});
|
||||
}
|
||||
|
||||
const candidateId = `candidate-${source.event.id}-${request.id}`;
|
||||
const sourceName = sourceLabel(source.event);
|
||||
const requestName = requestLabel(request);
|
||||
const requiredDecision = status === 'capture-required' ? 'capture-business-callable'
|
||||
: status === 'mapping-required' ? 'map-input'
|
||||
: status === 'ready' ? 'map-input'
|
||||
: destination ? 'map-input' : 'map-output';
|
||||
return {
|
||||
id: candidateId,
|
||||
recordingId: request.recordingId,
|
||||
traceId: request.traceId,
|
||||
target: { ...target },
|
||||
direction: 'request',
|
||||
request: {
|
||||
eventId: request.id,
|
||||
method: request.method || 'GET',
|
||||
url: request.url || '',
|
||||
destination,
|
||||
serialization,
|
||||
mappings: [{ sourceEventId: source.event.id, destination, serialization }],
|
||||
},
|
||||
source: {
|
||||
eventId: source.event.id,
|
||||
kind: source.event.kind,
|
||||
operation: source.event.operation,
|
||||
crypto: source.event.crypto,
|
||||
callHandleId: source.event.callHandleId,
|
||||
arguments: argumentRoles,
|
||||
destination,
|
||||
serialization,
|
||||
},
|
||||
sources: [{
|
||||
eventId: source.event.id,
|
||||
kind: source.event.kind,
|
||||
operation: source.event.operation,
|
||||
crypto: source.event.crypto,
|
||||
callHandleId: source.event.callHandleId,
|
||||
arguments: argumentRoles,
|
||||
destination,
|
||||
serialization,
|
||||
}],
|
||||
status,
|
||||
confidence: { score, level: confidenceLevel(score) },
|
||||
summary: replayReady
|
||||
? `已确认 ${sourceName} 的输出进入 ${destination},可生成明文网关`
|
||||
: exact && destination
|
||||
? `已确认 ${sourceName} 的输出进入 ${destination}`
|
||||
: `已定位 ${sourceName} 与 ${requestName},可继续捕获完整页面业务封装`,
|
||||
flow: [
|
||||
'明文输入(待确认)',
|
||||
...(source.inputEvents.length ? [`${source.inputEvents.length} 个输入准备步骤`] : []),
|
||||
sourceName,
|
||||
...(source.links.length > 1 ? [`${source.links.length - 1} 个中间转换`] : []),
|
||||
destination ? `${requestName} · ${destination}` : requestName,
|
||||
],
|
||||
pipeline: [
|
||||
{ id: `${candidateId}-input`, kind: 'context.read', label: '读取明文输入', source: '待确认' },
|
||||
{
|
||||
id: `${candidateId}-call`,
|
||||
kind: 'page.call',
|
||||
label: sourceName,
|
||||
callHandleId: source.event.callHandleId,
|
||||
},
|
||||
{
|
||||
id: `${candidateId}-output`,
|
||||
kind: 'output.write',
|
||||
label: destination ? `写入 ${destination}` : '确认请求输出位置',
|
||||
destination,
|
||||
},
|
||||
],
|
||||
evidence,
|
||||
missing,
|
||||
capturePlan: status === 'capture-required'
|
||||
? capturePlan(
|
||||
source.event.id,
|
||||
[...new Map([...source.inputEvents, ...source.stateEvents].map((event) => [event.id, event])).values()],
|
||||
[destination],
|
||||
)
|
||||
: undefined,
|
||||
aiContext: {
|
||||
valuePolicy: 'metadata-only',
|
||||
request: {
|
||||
eventId: request.id,
|
||||
method: request.method || 'GET',
|
||||
url: safeUrlMetadata(request.url) || '',
|
||||
destination,
|
||||
serialization,
|
||||
},
|
||||
source: {
|
||||
eventId: source.event.id,
|
||||
kind: source.event.kind,
|
||||
operation: source.event.operation,
|
||||
crypto: source.event.crypto,
|
||||
scriptUrl: safeUrlMetadata(source.event.scriptUrl),
|
||||
arguments: argumentRoles,
|
||||
},
|
||||
sources: [{
|
||||
eventId: source.event.id,
|
||||
operation: source.event.operation,
|
||||
crypto: source.event.crypto,
|
||||
destination,
|
||||
}],
|
||||
evidenceIds: evidence.map((item) => item.id),
|
||||
requiredDecision,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildUnknownBoundaryCandidate(
|
||||
target: BrowserTarget,
|
||||
request: BrowserRecordingEvent,
|
||||
): BrowserProfileInferenceCandidate {
|
||||
const requestName = requestLabel(request);
|
||||
const candidateId = `candidate-boundary-${request.id}`;
|
||||
const stackAvailable = Boolean(request.stack || request.scriptUrl);
|
||||
const evidence: BrowserProfileInferenceEvidence[] = [{
|
||||
id: `evidence-request-${request.id}`,
|
||||
kind: 'request-boundary',
|
||||
strength: 'proven',
|
||||
label: `真实请求边界:${requestName}`,
|
||||
eventIds: [request.id],
|
||||
}];
|
||||
if (stackAvailable) evidence.push({
|
||||
id: `evidence-stack-${request.id}`,
|
||||
kind: 'heuristic',
|
||||
strength: 'supported',
|
||||
label: '请求发生时保留了有界页面调用来源,可直接进入业务函数捕获',
|
||||
eventIds: [request.id],
|
||||
});
|
||||
const source = {
|
||||
eventId: request.id,
|
||||
kind: request.kind,
|
||||
operation: 'unknown-business-envelope',
|
||||
arguments: [],
|
||||
};
|
||||
const score = stackAvailable ? 45 : 35;
|
||||
return {
|
||||
id: candidateId,
|
||||
recordingId: request.recordingId,
|
||||
traceId: request.traceId,
|
||||
target: { ...target },
|
||||
direction: 'request',
|
||||
request: {
|
||||
eventId: request.id,
|
||||
method: request.method || 'GET',
|
||||
url: request.url || '',
|
||||
mappings: [],
|
||||
},
|
||||
source,
|
||||
sources: [source],
|
||||
status: 'capture-required',
|
||||
confidence: { score, level: confidenceLevel(score) },
|
||||
summary: `已定位 ${requestName} 的真实发送边界;算法或库未知不影响继续捕获`,
|
||||
flow: ['明文输入(待定位)', '页面业务封装(待捕获)', requestName],
|
||||
pipeline: [
|
||||
{ id: `${candidateId}-input`, kind: 'context.read', label: '读取明文输入', source: '由业务函数参数确认' },
|
||||
{ id: `${candidateId}-call`, kind: 'page.call', label: '页面业务封装' },
|
||||
{ id: `${candidateId}-output`, kind: 'output.write', label: '写入真实请求' },
|
||||
],
|
||||
evidence,
|
||||
missing: [{
|
||||
kind: 'business-callable',
|
||||
label: '没有发现可见的已知密码库调用;重复一次当前操作,插件会在真实请求边界暂停并推荐上层页面函数',
|
||||
action: 'capture-business-function',
|
||||
}],
|
||||
capturePlan: capturePlan(request.id, [request], []),
|
||||
aiContext: {
|
||||
valuePolicy: 'metadata-only',
|
||||
request: {
|
||||
eventId: request.id,
|
||||
method: request.method || 'GET',
|
||||
url: safeUrlMetadata(request.url) || '',
|
||||
},
|
||||
source: {
|
||||
eventId: request.id,
|
||||
kind: request.kind,
|
||||
operation: 'unknown-business-envelope',
|
||||
scriptUrl: safeUrlMetadata(request.scriptUrl),
|
||||
arguments: [],
|
||||
},
|
||||
sources: [{ eventId: request.id, operation: 'unknown-business-envelope' }],
|
||||
evidenceIds: evidence.map((item) => item.id),
|
||||
requiredDecision: 'capture-business-callable',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildRequestGraphCandidate(
|
||||
target: BrowserTarget,
|
||||
request: BrowserRecordingEvent,
|
||||
sources: LinkedSource[],
|
||||
): BrowserProfileInferenceCandidate {
|
||||
if (sources.length === 1) return buildCandidate(target, request, sources[0]);
|
||||
const members = [...sources]
|
||||
.sort((left, right) => left.event.sequence - right.event.sequence || left.event.id.localeCompare(right.event.id))
|
||||
.map((source) => buildCandidate(target, request, source));
|
||||
const primary = members[0];
|
||||
const graphSources = members.map((member) => member.source);
|
||||
const mappings = graphSources.map((source) => ({
|
||||
sourceEventId: source.eventId,
|
||||
destination: source.destination,
|
||||
serialization: source.serialization,
|
||||
}));
|
||||
const evidenceById = new Map<string, BrowserProfileInferenceEvidence>();
|
||||
for (const member of members) {
|
||||
for (const item of member.evidence) evidenceById.set(item.id, item);
|
||||
}
|
||||
const evidence = [...evidenceById.values()];
|
||||
const allMapped = graphSources.every((source) => Boolean(source.destination));
|
||||
const score = Math.max(0, Math.min(90, Math.min(...members.map((member) => member.confidence.score)) - 10));
|
||||
const requestName = requestLabel(request);
|
||||
const destinations = graphSources.map((source) => source.destination).filter((item): item is string => Boolean(item));
|
||||
const candidateId = `candidate-graph-${request.id}-${graphSources.map((source) => source.eventId).join('-')}`;
|
||||
const missing: BrowserProfileInferenceMissingStep[] = [{
|
||||
kind: 'business-callable',
|
||||
label: '同一请求包含多个相关密码调用;需要捕获上层业务封装,才能保持随机 Key、IV、Nonce 与各输出字段在每次回放中一致',
|
||||
action: 'capture-business-function',
|
||||
}];
|
||||
return {
|
||||
id: candidateId,
|
||||
recordingId: request.recordingId,
|
||||
traceId: request.traceId,
|
||||
target: { ...target },
|
||||
direction: 'request',
|
||||
request: {
|
||||
eventId: request.id,
|
||||
method: request.method || 'GET',
|
||||
url: request.url || '',
|
||||
mappings,
|
||||
},
|
||||
source: primary.source,
|
||||
sources: graphSources,
|
||||
status: 'capture-required',
|
||||
confidence: { score, level: confidenceLevel(score) },
|
||||
summary: allMapped
|
||||
? `已确认 ${graphSources.length} 个密码调用分别进入 ${destinations.join('、')},需要保留它们的动态值关系`
|
||||
: `已识别 ${graphSources.length} 个密码调用与 ${requestName} 的请求级数据流`,
|
||||
flow: [
|
||||
'明文与动态参数',
|
||||
`${graphSources.length} 个关联密码调用`,
|
||||
allMapped ? `${requestName} · ${destinations.length} 个字段` : requestName,
|
||||
],
|
||||
pipeline: graphSources.flatMap((source, index) => [
|
||||
{ id: `${candidateId}-input-${index}`, kind: 'context.read' as const, label: `读取调用 ${index + 1} 输入`, source: '待由业务封装确认' },
|
||||
{ id: `${candidateId}-call-${index}`, kind: 'page.call' as const, label: source.crypto ? source.crypto.operation : source.operation, callHandleId: source.callHandleId },
|
||||
{ id: `${candidateId}-output-${index}`, kind: 'output.write' as const, label: source.destination ? `写入 ${source.destination}` : '确认输出位置', destination: source.destination },
|
||||
]),
|
||||
evidence,
|
||||
missing,
|
||||
capturePlan: capturePlan(
|
||||
primary.source.eventId,
|
||||
[...new Map(sources.flatMap((source) => [...source.inputEvents, ...source.stateEvents]).map((event) => [event.id, event])).values()],
|
||||
destinations,
|
||||
),
|
||||
aiContext: {
|
||||
valuePolicy: 'metadata-only',
|
||||
request: {
|
||||
eventId: request.id,
|
||||
method: request.method || 'GET',
|
||||
url: safeUrlMetadata(request.url) || '',
|
||||
},
|
||||
source: primary.aiContext.source,
|
||||
sources: graphSources.map((source) => ({
|
||||
eventId: source.eventId,
|
||||
operation: source.operation,
|
||||
crypto: source.crypto,
|
||||
destination: source.destination,
|
||||
})),
|
||||
evidenceIds: evidence.map((item) => item.id),
|
||||
requiredDecision: 'capture-business-callable',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function inferBrowserTransformProfiles(input: BrowserProfileInferenceInput): BrowserProfileInferenceCandidate[] {
|
||||
const events = [...input.events].sort((left, right) => left.sequence - right.sequence);
|
||||
const eventsById = new Map(events.map((event) => [event.id, event]));
|
||||
const incoming = new Map<string, BrowserRecordingLink[]>();
|
||||
for (const link of input.links) incoming.set(link.toEventId, [...(incoming.get(link.toEventId) || []), link]);
|
||||
const output: BrowserProfileInferenceCandidate[] = [];
|
||||
for (const request of events.filter(isRequestEvent)) {
|
||||
const exactSources = linkedSources(request, eventsById, incoming);
|
||||
const sources = exactSources.length
|
||||
? exactSources
|
||||
: [temporalSource(request, events, eventsById, incoming)].filter((item): item is LinkedSource => Boolean(item));
|
||||
output.push(sources.length
|
||||
? buildRequestGraphCandidate(input.target, request, sources)
|
||||
: buildUnknownBoundaryCandidate(input.target, request));
|
||||
}
|
||||
return output
|
||||
.sort((left, right) => right.confidence.score - left.confidence.score
|
||||
|| left.source.eventId.localeCompare(right.source.eventId))
|
||||
.slice(0, MAX_CANDIDATES);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { inferBusinessFrameHints, parseRecordingStack } from './stack-hints';
|
||||
|
||||
describe('recording stack business hints', () => {
|
||||
it('removes recorder and dependency frames while keeping page callers', () => {
|
||||
const frames = parseRecordingStack([
|
||||
'Error',
|
||||
' at recordedEncrypt (chrome-extension://extension/page-recorder-main-world.js:1:10)',
|
||||
' at Object.encrypt (https://example.test/assets/crypto-js.min.js:2:20)',
|
||||
' at buildEnvelope (https://example.test/assets/app.js?v=7:41:9)',
|
||||
' at submitLogin (https://example.test/assets/app.js?v=7:63:5)',
|
||||
].join('\n'));
|
||||
|
||||
expect(frames).toEqual([
|
||||
{ functionName: 'buildEnvelope', url: 'https://example.test/assets/app.js', depth: 0 },
|
||||
{ functionName: 'submitLogin', url: 'https://example.test/assets/app.js', depth: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('finds the nearest common business ancestor across independently named crypto calls', () => {
|
||||
const hints = inferBusinessFrameHints([
|
||||
{
|
||||
stack: 'at aesPrimitive (https://example.test/aes.js:1:1)\n at assemblePacket (https://example.test/app.js:40:2)\n at onclick (https://example.test/app.js:90:1)',
|
||||
scriptUrl: 'https://example.test/aes.js',
|
||||
},
|
||||
{
|
||||
stack: 'at rsaPrimitive (https://example.test/rsa.js:1:1)\n at assemblePacket (https://example.test/app.js:52:2)\n at onclick (https://example.test/app.js:90:1)',
|
||||
scriptUrl: 'https://example.test/rsa.js',
|
||||
},
|
||||
{
|
||||
stack: 'at wrapKey (https://example.test/rsa.js:8:1)\n at assemblePacket (https://example.test/app.js:58:2)\n at onclick (https://example.test/app.js:90:1)',
|
||||
scriptUrl: 'https://example.test/rsa.js',
|
||||
},
|
||||
] as never);
|
||||
|
||||
expect(hints[0]).toMatchObject({ functionName: 'assemblePacket', support: 3, averageDepth: 1 });
|
||||
expect(hints[1]).toMatchObject({ functionName: 'onclick', support: 3 });
|
||||
expect(hints.some((hint) => hint.functionName === 'aesPrimitive')).toBe(false);
|
||||
});
|
||||
|
||||
it('does not manufacture a common frame when one stack is unavailable', () => {
|
||||
expect(inferBusinessFrameHints([
|
||||
{ stack: 'at build (https://example.test/app.js:1:1)' },
|
||||
{ stack: undefined },
|
||||
] as never)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { BrowserBusinessFrameHint, BrowserRecordingEvent } from '@/types/models';
|
||||
|
||||
const MAX_STACK_EVENTS = 8;
|
||||
const MAX_STACK_LINES = 16;
|
||||
const MAX_HINTS = 8;
|
||||
const RECORDER_FUNCTION = /^recorded[A-Z]|^pauseForDeepCapture$|^stackInfo$/;
|
||||
|
||||
interface ParsedStackFrame {
|
||||
functionName: string;
|
||||
url?: string;
|
||||
depth: number;
|
||||
}
|
||||
|
||||
function normalizeFunctionName(value: string): string {
|
||||
const withoutAlias = value.replace(/\s+\[as\s+[^\]]+\]$/, '').replace(/^(?:async\s+|new\s+)/, '').trim();
|
||||
const segments = withoutAlias.split('.');
|
||||
return (segments.at(-1) || withoutAlias || '(anonymous)').replace(/^Object\./, '');
|
||||
}
|
||||
|
||||
function normalizeScriptUrl(value?: string): string | undefined {
|
||||
if (!value) return undefined;
|
||||
try {
|
||||
const url = new URL(value);
|
||||
url.search = '';
|
||||
url.hash = '';
|
||||
return url.toString();
|
||||
} catch {
|
||||
return value.split(/[?#]/, 1)[0].slice(0, 2_048) || undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function dependencyFrame(functionName: string, url?: string): boolean {
|
||||
const value = `${functionName}\n${url || ''}`.toLowerCase();
|
||||
return RECORDER_FUNCTION.test(functionName)
|
||||
|| value.includes('chrome-extension://')
|
||||
|| value.includes('page-recorder-main-world')
|
||||
|| value.includes('/node_modules/')
|
||||
|| value.includes('crypto-js')
|
||||
|| value.includes('jsencrypt')
|
||||
|| value.includes('node-forge')
|
||||
|| value.includes('sm-crypto')
|
||||
|| value.includes('webpack/runtime');
|
||||
}
|
||||
|
||||
export function parseRecordingStack(stack?: string, fallbackUrl?: string): ParsedStackFrame[] {
|
||||
if (!stack) return [];
|
||||
const output: ParsedStackFrame[] = [];
|
||||
for (const rawLine of stack.split('\n').slice(0, MAX_STACK_LINES)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line) continue;
|
||||
const location = line.match(/((?:(?:https?|file|blob|webpack|chrome-extension):\/\/|\/)[^\s)]+):(\d+):(\d+)\)?$/);
|
||||
if (!location) continue;
|
||||
const url = normalizeScriptUrl(location[1] || fallbackUrl);
|
||||
let prefix = location ? line.slice(0, location.index).trim() : line;
|
||||
prefix = prefix.replace(/^at\s+/, '').replace(/\($/, '').replace(/@$/, '').trim();
|
||||
const functionName = normalizeFunctionName(prefix || '(anonymous)');
|
||||
if (dependencyFrame(functionName, url)) continue;
|
||||
output.push({ functionName, url, depth: output.length });
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
export function inferBusinessFrameHints(
|
||||
events: Array<Pick<BrowserRecordingEvent, 'stack' | 'scriptUrl'>>,
|
||||
): BrowserBusinessFrameHint[] {
|
||||
const bounded = events.slice(0, MAX_STACK_EVENTS);
|
||||
if (!bounded.length) return [];
|
||||
const parsed = bounded.map((event) => parseRecordingStack(event.stack, event.scriptUrl));
|
||||
if (parsed.some((frames) => !frames.length)) return [];
|
||||
const evidence = new Map<string, { functionName: string; url?: string; depths: number[]; events: Set<number> }>();
|
||||
parsed.forEach((frames, eventIndex) => {
|
||||
const seen = new Set<string>();
|
||||
for (const frame of frames) {
|
||||
const key = `${frame.functionName}\n${frame.url || ''}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
const current = evidence.get(key) || {
|
||||
functionName: frame.functionName,
|
||||
url: frame.url,
|
||||
depths: [],
|
||||
events: new Set<number>(),
|
||||
};
|
||||
current.depths.push(frame.depth);
|
||||
current.events.add(eventIndex);
|
||||
evidence.set(key, current);
|
||||
}
|
||||
});
|
||||
const requiredSupport = bounded.length;
|
||||
return [...evidence.values()]
|
||||
.filter((item) => item.events.size === requiredSupport)
|
||||
.map((item) => ({
|
||||
functionName: item.functionName.slice(0, 240),
|
||||
url: item.url?.slice(0, 4_096),
|
||||
support: item.events.size,
|
||||
averageDepth: item.depths.reduce((sum, depth) => sum + depth, 0) / Math.max(1, item.depths.length),
|
||||
}))
|
||||
.sort((left, right) => left.averageDepth - right.averageDepth
|
||||
|| left.functionName.localeCompare(right.functionName)
|
||||
|| (left.url || '').localeCompare(right.url || ''))
|
||||
.slice(0, MAX_HINTS);
|
||||
}
|
||||
Reference in New Issue
Block a user