mirror of
https://github.com/yaklang/yaklang-chrome-extension.git
synced 2026-09-25 20:51:52 +08:00
feat: advance browser agent integration workflows
This commit is contained in:
@@ -0,0 +1,365 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
AlertTriangle, ArrowRight, Check, CircleCheck, Code2, FileDiff, FileText, Timer,
|
||||
} from 'lucide-react';
|
||||
import { errorMessage } from '@/platform/messaging/runtime';
|
||||
import {
|
||||
runBrowserAuthorizationTask,
|
||||
type BrowserAuthorizationEvidenceBundle,
|
||||
type BrowserAuthorizationEvidenceDiff,
|
||||
type BrowserAuthorizationEvidencePacket,
|
||||
type BrowserAuthorizationEvidenceValidation,
|
||||
type BrowserAuthorizationWorkspace,
|
||||
} from '../engine';
|
||||
|
||||
function decodeEvidencePacket(packetBase64: string): string {
|
||||
const binary = atob(packetBase64);
|
||||
const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0));
|
||||
return new TextDecoder().decode(bytes);
|
||||
}
|
||||
|
||||
export function compactDuration(value: number): string {
|
||||
if (!Number.isFinite(value)) return '—';
|
||||
if (value < 1) return `${value.toFixed(2)} ms`;
|
||||
if (value < 100) return `${value.toFixed(1)} ms`;
|
||||
return `${Math.round(value)} ms`;
|
||||
}
|
||||
|
||||
function formatResponseAnalysis(response?: BrowserAuthorizationEvidenceBundle['cases'][number]['response']): string {
|
||||
if (!response) return '';
|
||||
if (response.analysisState === 'encoded-unavailable') return ' · 编码正文不可分析';
|
||||
if (response.analysisRepresentation === 'binary') return ' · 二进制摘要';
|
||||
if (response.decoded) {
|
||||
const encoding = response.contentEncoding || '压缩内容';
|
||||
const representation = response.analysisRepresentation?.toUpperCase() || '正文';
|
||||
return ` · ${encoding} → ${representation}`;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
export function AuthorizationEvidenceWorkbench({
|
||||
workspace,
|
||||
onWorkspaceChange,
|
||||
}: {
|
||||
workspace: BrowserAuthorizationWorkspace;
|
||||
onWorkspaceChange: (workspace: BrowserAuthorizationWorkspace) => void;
|
||||
}) {
|
||||
const execution = workspace.execution!;
|
||||
const [bundle, setBundle] = useState<BrowserAuthorizationEvidenceBundle>();
|
||||
const [comparisonId, setComparisonId] = useState('');
|
||||
const [diff, setDiff] = useState<BrowserAuthorizationEvidenceDiff>();
|
||||
const [packet, setPacket] = useState<BrowserAuthorizationEvidencePacket>();
|
||||
const [packetTitle, setPacketTitle] = useState('');
|
||||
const [view, setView] = useState<'redacted' | 'raw'>('redacted');
|
||||
const [showVolatile, setShowVolatile] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [validatingPath, setValidatingPath] = useState('');
|
||||
const [validationMessage, setValidationMessage] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
setLoading(true);
|
||||
setError('');
|
||||
setBundle(undefined);
|
||||
setDiff(undefined);
|
||||
setPacket(undefined);
|
||||
void runBrowserAuthorizationTask<BrowserAuthorizationEvidenceBundle>(
|
||||
'authorization.evidence.inspect',
|
||||
{ workspaceId: workspace.id, executionId: execution.id },
|
||||
).then((next) => {
|
||||
if (disposed) return;
|
||||
setBundle(next);
|
||||
const preferred = next.comparisons.find((item) => item.purpose === 'authorization')
|
||||
|| next.comparisons[0];
|
||||
setComparisonId(preferred?.id || '');
|
||||
}).catch((cause) => {
|
||||
if (!disposed) setError(errorMessage(cause));
|
||||
}).finally(() => {
|
||||
if (!disposed) setLoading(false);
|
||||
});
|
||||
return () => { disposed = true; };
|
||||
}, [execution.id, workspace.id]);
|
||||
|
||||
const comparison = bundle?.comparisons.find((item) => item.id === comparisonId);
|
||||
const comparisonCases = comparison
|
||||
? bundle?.cases.filter((item) => item.id === comparison.leftCaseId || item.id === comparison.rightCaseId) || []
|
||||
: [];
|
||||
const comparisonTruncated = comparisonCases.some((item) => item.response?.truncated);
|
||||
const comparisonEncodedUnavailable = comparisonCases.some(
|
||||
(item) => item.response?.analysisState === 'encoded-unavailable',
|
||||
);
|
||||
const rawDiffEntries = diff?.entries;
|
||||
const diffEntries = Array.isArray(rawDiffEntries) ? rawDiffEntries : [];
|
||||
const diffRepresentationLabel = diff?.representation === 'structured'
|
||||
? '结构化字段差异'
|
||||
: diffEntries.some((entry) => entry.path.includes('.body.binary.'))
|
||||
? '二进制摘要差异'
|
||||
: diffEntries.some((entry) => entry.path.includes('.body.encoded.'))
|
||||
? '编码正文元数据差异'
|
||||
: '原始文本差异';
|
||||
const volatileCount = diffEntries.filter((entry) => entry.volatile).length;
|
||||
const visibleEntries = diffEntries.filter((entry) => showVolatile || !entry.volatile);
|
||||
const executionEvidence = Array.isArray(execution.evidence) ? execution.evidence : [];
|
||||
const validationDirections: BrowserAuthorizationEvidenceValidation['direction'][] = comparison?.id === 'controls'
|
||||
? ['a-to-b', 'b-to-a']
|
||||
: comparison?.id === 'a-to-b'
|
||||
? ['a-to-b']
|
||||
: comparison?.id === 'b-to-a'
|
||||
? ['b-to-a']
|
||||
: comparison?.id === 'low-vs-privileged' || comparison?.id === 'probe-vs-privileged'
|
||||
? ['low-to-privileged']
|
||||
: comparison?.id === 'post-state'
|
||||
? ['post-state']
|
||||
: [];
|
||||
|
||||
useEffect(() => {
|
||||
if (!comparison) return;
|
||||
let disposed = false;
|
||||
setLoading(true);
|
||||
setError('');
|
||||
setPacket(undefined);
|
||||
void runBrowserAuthorizationTask<BrowserAuthorizationEvidenceDiff>(
|
||||
'authorization.evidence.diff',
|
||||
{
|
||||
workspaceId: workspace.id,
|
||||
executionId: execution.id,
|
||||
leftCaseId: comparison.leftCaseId,
|
||||
rightCaseId: comparison.rightCaseId,
|
||||
scope: 'response',
|
||||
view,
|
||||
},
|
||||
).then((next) => {
|
||||
if (!disposed) setDiff(next);
|
||||
}).catch((cause) => {
|
||||
if (!disposed) setError(errorMessage(cause));
|
||||
}).finally(() => {
|
||||
if (!disposed) setLoading(false);
|
||||
});
|
||||
return () => { disposed = true; };
|
||||
}, [comparison?.id, execution.id, view, workspace.id]);
|
||||
|
||||
const changeView = (next: 'redacted' | 'raw') => {
|
||||
if (next === 'raw' && !window.confirm(
|
||||
'原始证据可能包含 Cookie、Authorization 与业务敏感值。仅在当前授权测试确有需要时显示。',
|
||||
)) return;
|
||||
setView(next);
|
||||
setPacket(undefined);
|
||||
};
|
||||
|
||||
const openPacket = async (
|
||||
caseId: string,
|
||||
side: 'request' | 'response',
|
||||
label: string,
|
||||
) => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const next = await runBrowserAuthorizationTask<BrowserAuthorizationEvidencePacket>(
|
||||
'authorization.evidence.packet',
|
||||
{
|
||||
workspaceId: workspace.id,
|
||||
executionId: execution.id,
|
||||
caseId,
|
||||
side,
|
||||
view,
|
||||
},
|
||||
);
|
||||
setPacket(next);
|
||||
setPacketTitle(`${label} · ${side === 'request' ? '请求' : '响应'}`);
|
||||
} catch (cause) {
|
||||
setError(errorMessage(cause));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const validatePath = async (
|
||||
path: string,
|
||||
direction: BrowserAuthorizationEvidenceValidation['direction'],
|
||||
) => {
|
||||
const validationKey = `${direction}:${path}`;
|
||||
setValidatingPath(validationKey);
|
||||
setValidationMessage('');
|
||||
setError('');
|
||||
try {
|
||||
const validation = await runBrowserAuthorizationTask<BrowserAuthorizationEvidenceValidation>(
|
||||
'authorization.evidence.validate',
|
||||
{
|
||||
workspaceId: workspace.id,
|
||||
executionId: execution.id,
|
||||
direction,
|
||||
paths: [path],
|
||||
},
|
||||
);
|
||||
setValidationMessage(validation.reason);
|
||||
const validationEvidence = Array.isArray(validation.evidence) ? validation.evidence : [];
|
||||
const additions = validationEvidence.filter((candidate) => !executionEvidence.some((current) => (
|
||||
current.direction === candidate.direction
|
||||
&& current.path === candidate.path
|
||||
&& current.source === candidate.source
|
||||
)));
|
||||
onWorkspaceChange({
|
||||
...workspace,
|
||||
execution: {
|
||||
...execution,
|
||||
verdict: validation.verdict,
|
||||
confidence: validation.confidence,
|
||||
evidence: [...executionEvidence, ...additions],
|
||||
reasons: validation.verdictChanged
|
||||
? [...execution.reasons, validation.reason]
|
||||
: execution.reasons,
|
||||
},
|
||||
});
|
||||
} catch (cause) {
|
||||
setError(errorMessage(cause));
|
||||
} finally {
|
||||
setValidatingPath('');
|
||||
}
|
||||
};
|
||||
|
||||
return <div className="authorization-evidence-workbench">
|
||||
<div className="authorization-evidence-title">
|
||||
<div>
|
||||
<span>短时证据包</span>
|
||||
<strong>交叉请求与业务归属证据</strong>
|
||||
<small>
|
||||
报文仅在当前工作区短时保留;差异默认脱敏,时间戳与请求 ID 会单独降噪。
|
||||
{bundle ? ` · 保留至 ${new Date(bundle.expiresAt).toLocaleTimeString()}` : ''}
|
||||
</small>
|
||||
</div>
|
||||
<div className="authorization-evidence-view">
|
||||
<button className={view === 'redacted' ? 'active' : ''} onClick={() => changeView('redacted')}>脱敏</button>
|
||||
<button className={view === 'raw' ? 'active raw' : ''} onClick={() => changeView('raw')}>原始值</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{bundle && <div className="authorization-evidence-trace" aria-label="测试请求执行顺序">
|
||||
{bundle.cases.map((item, index) => <div key={item.id}>
|
||||
<span>{String(index + 1).padStart(2, '0')}</span>
|
||||
<strong>{item.label}</strong>
|
||||
<small>
|
||||
{item.status || '—'} · {compactDuration(item.timing.totalMs)}
|
||||
{item.timing.ttfbMs > 0 ? ` · 首字节 ${compactDuration(item.timing.ttfbMs)}` : ''}
|
||||
{formatResponseAnalysis(item.response)}
|
||||
</small>
|
||||
<nav>
|
||||
<button disabled={!item.requestAvailable || loading} onClick={() => void openPacket(item.id, 'request', item.label)}>
|
||||
<Code2 size={12} />请求
|
||||
</button>
|
||||
<button disabled={!item.responseAvailable || loading} onClick={() => void openPacket(item.id, 'response', item.label)}>
|
||||
<FileText size={12} />响应
|
||||
</button>
|
||||
</nav>
|
||||
</div>)}
|
||||
</div>}
|
||||
|
||||
<div className="authorization-evidence-body">
|
||||
<aside>
|
||||
<span>比较关系</span>
|
||||
{bundle?.comparisons.map((item) => <button
|
||||
key={item.id}
|
||||
className={item.id === comparisonId ? 'active' : ''}
|
||||
onClick={() => {
|
||||
setComparisonId(item.id);
|
||||
setPacket(undefined);
|
||||
}}
|
||||
>
|
||||
<i>{item.purpose === 'authorization' ? '关键' : item.purpose === 'state-change' ? '状态' : '对照'}</i>
|
||||
<strong>{item.label}</strong>
|
||||
</button>)}
|
||||
</aside>
|
||||
<main>
|
||||
<header>
|
||||
<div>
|
||||
{packet ? <FileText size={16} /> : <FileDiff size={16} />}
|
||||
<span><strong>{packet ? packetTitle : comparison?.label || '响应差异'}</strong>
|
||||
<small>{packet
|
||||
? `${packet.view === 'raw' ? '原始' : '脱敏'}报文${packet.truncated ? ' · 已截断' : ''}`
|
||||
: diffRepresentationLabel}</small>
|
||||
</span>
|
||||
</div>
|
||||
{packet
|
||||
? <button onClick={() => setPacket(undefined)}><FileDiff size={13} />返回差异</button>
|
||||
: volatileCount > 0 && <button onClick={() => setShowVolatile((current) => !current)}>
|
||||
{showVolatile ? '隐藏' : '显示'}动态噪声 · {volatileCount}
|
||||
</button>}
|
||||
</header>
|
||||
|
||||
{loading && <div className="authorization-evidence-empty"><Timer size={17} />正在读取证据…</div>}
|
||||
{!loading && error && <div className="authorization-evidence-empty error"><AlertTriangle size={17} />{error}</div>}
|
||||
{!loading && !error && packet && <pre>{decodeEvidencePacket(packet.packetBase64)}</pre>}
|
||||
{!loading && !error && !packet && diff?.equal && <div className="authorization-evidence-empty">
|
||||
<CircleCheck size={17} />{comparison?.purpose === 'authorization'
|
||||
? comparisonTruncated
|
||||
? '两项响应已捕获部分一致,但至少一项已截断,不能据此判断资源归属。'
|
||||
: comparisonEncodedUnavailable
|
||||
? '两项线上编码正文指纹一致,但正文未能在预算内解码,不能据此提升授权结论。'
|
||||
: '交叉响应与目标身份响应完全一致;如结论尚未确认,请切换到“身份 A 自有资源 ↔ 身份 B 自有资源”,选择稳定业务字段验证。'
|
||||
: comparison?.purpose === 'state-change'
|
||||
? '操作前后的稳定业务字段没有变化。'
|
||||
: '双方正常响应完全一致,当前对照没有可用于区分资源归属的字段。'}
|
||||
</div>}
|
||||
{!loading && !error && !packet && diff && !diff.equal
|
||||
&& visibleEntries.length === 0 && volatileCount > 0 && !showVolatile
|
||||
&& <div className="authorization-evidence-empty">
|
||||
<Timer size={17} />当前差异只有 {volatileCount} 项动态噪声,已默认折叠。
|
||||
</div>}
|
||||
{!packet && validationMessage && <div className="authorization-evidence-validation">
|
||||
<Check size={13} />{validationMessage}
|
||||
</div>}
|
||||
{!loading && !error && !packet && diff && !diff.equal && visibleEntries.length > 0 && <div className="authorization-diff-list">
|
||||
{visibleEntries.slice(0, 80).map((entry) => {
|
||||
const pendingDirections = validationDirections.filter((direction) => !executionEvidence.some((item) => (
|
||||
item.path === entry.path && item.direction === direction
|
||||
)));
|
||||
const alreadyVerified = pendingDirections.length < validationDirections.length;
|
||||
const canValidate = Boolean(
|
||||
pendingDirections.length
|
||||
&& diff.scope === 'response'
|
||||
&& entry.path.startsWith('body.')
|
||||
&& !entry.volatile
|
||||
&& !entry.sensitive
|
||||
);
|
||||
return <div
|
||||
key={`${entry.path}-${entry.kind}`}
|
||||
className={`${entry.semantic || alreadyVerified ? 'semantic' : ''} ${entry.volatile ? 'volatile' : ''}`}
|
||||
>
|
||||
<div>
|
||||
<code>{entry.path}</code>
|
||||
<span>{alreadyVerified
|
||||
? pendingDirections.length ? '部分已验证' : '已验证'
|
||||
: entry.semantic ? '归属候选' : entry.volatile ? '动态噪声' : entry.sensitive ? '敏感字段' : entry.kind}</span>
|
||||
{canValidate && pendingDirections.map((direction) => {
|
||||
const validationKey = `${direction}:${entry.path}`;
|
||||
const label = direction === 'a-to-b'
|
||||
? '验证 A→B'
|
||||
: direction === 'b-to-a'
|
||||
? '验证 B→A'
|
||||
: direction === 'post-state'
|
||||
? '验证状态变化'
|
||||
: '核对低权探测';
|
||||
return <button
|
||||
key={direction}
|
||||
disabled={Boolean(validatingPath)}
|
||||
onClick={() => void validatePath(entry.path, direction)}
|
||||
>
|
||||
{validatingPath === validationKey ? '验证中…' : label}
|
||||
</button>;
|
||||
})}
|
||||
</div>
|
||||
<section>
|
||||
<p><b>左</b><span title={entry.left}>{entry.left || '—'}</span></p>
|
||||
<ArrowRight size={13} />
|
||||
<p><b>右</b><span title={entry.right}>{entry.right || '—'}</span></p>
|
||||
</section>
|
||||
</div>;
|
||||
})}
|
||||
{(visibleEntries.length > 80 || diff.omitted > 0) && <small className="authorization-diff-omitted">
|
||||
当前展示前 80 项,另有 {Math.max(0, visibleEntries.length - 80) + diff.omitted} 项未展开
|
||||
</small>}
|
||||
</div>}
|
||||
</main>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,977 @@
|
||||
import { useCallback, useEffect, useMemo, useReducer, useState } from 'react';
|
||||
import { browser } from 'wxt/browser';
|
||||
import {
|
||||
AlertTriangle, ArrowRight, Check, CircleCheck, ExternalLink, Fingerprint,
|
||||
LockKeyhole, Play, RefreshCw, RotateCcw, ShieldAlert, Square, UserRoundPlus,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { authorizationShareGrantInput } from '@/features/grants/gateway-share';
|
||||
import { errorMessage, request } from '@/platform/messaging/runtime';
|
||||
import type {
|
||||
ActiveTabInfo, BridgeStatus, BrowserIsolationContext, BrowserIsolationInspection,
|
||||
ExtensionState, NetworkCaptureStatus,
|
||||
} from '@/types/models';
|
||||
import {
|
||||
runBrowserAuthorizationTask,
|
||||
type BrowserAuthorizationBaselineCandidate,
|
||||
type BrowserAuthorizationMode,
|
||||
type BrowserAuthorizationSide,
|
||||
type BrowserAuthorizationWorkspace,
|
||||
} from '../engine';
|
||||
import './authorization-testing-workspace.css';
|
||||
import {
|
||||
authorizationIdentityOptionDisabledReason,
|
||||
normalizeAuthorizationIdentityTabSelection,
|
||||
} from './identity-selection';
|
||||
import {
|
||||
authorizationWorkspaceUIReducer,
|
||||
INITIAL_AUTHORIZATION_WORKSPACE_UI,
|
||||
persistedAuthorizationWorkspaceUI,
|
||||
} from './workspace-reducer';
|
||||
import {
|
||||
AuthorizationEvidenceWorkbench,
|
||||
compactDuration,
|
||||
} from './AuthorizationEvidenceWorkbench';
|
||||
import { IdentitySlot } from './IdentitySlot';
|
||||
|
||||
const SESSION_KEY = 'session.authorization-testing-workspace-ui.v1';
|
||||
|
||||
interface AuthorizationTestingWorkspaceProps {
|
||||
state: ExtensionState;
|
||||
setState: (state: ExtensionState) => void;
|
||||
tabs: ActiveTabInfo[];
|
||||
activeTab?: ActiveTabInfo;
|
||||
bridge: BridgeStatus;
|
||||
refreshTabs: () => Promise<void>;
|
||||
run: (task: () => Promise<void>, success?: string) => Promise<void>;
|
||||
busy: boolean;
|
||||
}
|
||||
|
||||
function tabOrigin(tab?: ActiveTabInfo): string {
|
||||
try {
|
||||
return tab ? new URL(tab.url).origin : '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function shortHost(tab?: ActiveTabInfo): string {
|
||||
try {
|
||||
return tab ? new URL(tab.url).host : '未选择页面';
|
||||
} catch {
|
||||
return '未选择页面';
|
||||
}
|
||||
}
|
||||
|
||||
function formatWorkspaceRemaining(expiresAt: number, now: number): string {
|
||||
const remainingSeconds = Math.max(0, Math.ceil((expiresAt - now) / 1_000));
|
||||
if (remainingSeconds < 60) return `${remainingSeconds} 秒`;
|
||||
const minutes = Math.ceil(remainingSeconds / 60);
|
||||
return minutes < 60 ? `${minutes} 分钟` : `${Math.floor(minutes / 60)} 小时 ${minutes % 60} 分钟`;
|
||||
}
|
||||
|
||||
function contextForTab(
|
||||
inspection: BrowserIsolationInspection | undefined,
|
||||
tabId: number | undefined,
|
||||
): BrowserIsolationContext | undefined {
|
||||
return inspection?.contexts.find((context) => tabId && context.tabIds.includes(tabId));
|
||||
}
|
||||
|
||||
function proofLabel(workspace?: BrowserAuthorizationWorkspace): string {
|
||||
if (!workspace) return '尚未验证';
|
||||
if (workspace.proof.level === 'strong') return '强隔离';
|
||||
if (workspace.proof.level === 'conditional') return '条件隔离';
|
||||
return '隔离不足';
|
||||
}
|
||||
|
||||
function relationLabel(value: 'different' | 'same' | 'unknown'): string {
|
||||
if (value === 'different') return '不同';
|
||||
if (value === 'same') return '相同';
|
||||
return '待确认';
|
||||
}
|
||||
|
||||
function authenticationStatusLabel(
|
||||
value: BrowserAuthorizationWorkspace['left']['authentication']['status'],
|
||||
): string {
|
||||
if (value === 'authenticated') return '已识别登录态';
|
||||
if (value === 'unauthenticated') return '未检测到登录态';
|
||||
return '登录信号待识别';
|
||||
}
|
||||
|
||||
function verdictCopy(
|
||||
verdict: NonNullable<BrowserAuthorizationWorkspace['execution']>['verdict'],
|
||||
mode: BrowserAuthorizationMode,
|
||||
): {
|
||||
title: string;
|
||||
detail: string;
|
||||
tone: 'danger' | 'success' | 'warning' | 'muted';
|
||||
} {
|
||||
switch (verdict) {
|
||||
case 'confirmed':
|
||||
return {
|
||||
title: mode === 'vertical' ? '已确认低权限操作生效' : '已确认跨身份数据访问',
|
||||
detail: mode === 'vertical'
|
||||
? '低权限身份发起操作后出现了独立可验证的业务状态变化;是否违反策略仍需结合角色定义。'
|
||||
: '一个身份用自己的登录态取得了另一身份正常响应中的稳定业务数据;是否构成缺陷取决于两身份权限关系与业务策略。',
|
||||
tone: 'warning',
|
||||
};
|
||||
case 'likely':
|
||||
return {
|
||||
title: mode === 'vertical' ? '低权限操作可能被接受' : '观察到跨身份响应吻合',
|
||||
detail: mode === 'vertical'
|
||||
? '低权限探测被服务端接受,但还缺少独立的操作后状态证据。'
|
||||
: '交叉响应与目标身份的正常响应精确吻合,但尚缺稳定归属字段与同权限策略证据。',
|
||||
tone: 'warning',
|
||||
};
|
||||
case 'protected':
|
||||
return {
|
||||
title: '当前样本受到保护',
|
||||
detail: mode === 'vertical'
|
||||
? '正常控制成立,低权限身份执行目标高权限动作时被明确拒绝。'
|
||||
: '双方正常访问成立,两项交叉访问均未取得对方资源。',
|
||||
tone: 'success',
|
||||
};
|
||||
case 'invalid-controls':
|
||||
return { title: '对照样本无效', detail: '正常对照没有建立,不能据此判断授权边界。', tone: 'warning' };
|
||||
default:
|
||||
return { title: '证据不足', detail: '本轮结果不能形成稳定结论,请检查基线和响应语义。', tone: 'muted' };
|
||||
}
|
||||
}
|
||||
|
||||
function confidenceLabel(
|
||||
confidence: NonNullable<BrowserAuthorizationWorkspace['execution']>['confidence'],
|
||||
): string {
|
||||
if (confidence === 'high') return '高';
|
||||
if (confidence === 'medium') return '中';
|
||||
if (confidence === 'low') return '低';
|
||||
return '无';
|
||||
}
|
||||
|
||||
function authorizationOutcomeLabel(value?: string): string {
|
||||
if (value === 'success') return '成功';
|
||||
if (value === 'denied') return '明确拒绝';
|
||||
if (value === 'redirect') return '重定向';
|
||||
if (value === 'client-error') return '客户端错误';
|
||||
if (value === 'server-error') return '服务端错误';
|
||||
if (value === 'opaque') return '响应不可读';
|
||||
if (value === 'completed') return '已完成';
|
||||
if (value === 'failed') return '失败';
|
||||
if (value === 'skipped') return '已跳过';
|
||||
return value || '未执行';
|
||||
}
|
||||
|
||||
function candidateLabel(candidate: BrowserAuthorizationBaselineCandidate): string {
|
||||
const status = candidate.statusCode ? ` · ${candidate.statusCode}` : '';
|
||||
let target = candidate.path;
|
||||
try {
|
||||
const parsed = new URL(candidate.url);
|
||||
target = `${parsed.pathname}${parsed.search}`;
|
||||
} catch {
|
||||
// The bounded path supplied by Yak remains the fallback.
|
||||
}
|
||||
return `${candidate.method} ${target}${status}`;
|
||||
}
|
||||
|
||||
function authorizationCandidateRoute(candidate: BrowserAuthorizationBaselineCandidate): string {
|
||||
try {
|
||||
const parsed = new URL(candidate.url);
|
||||
const normalizedPath = parsed.pathname
|
||||
.split('/')
|
||||
.map((segment) => {
|
||||
if (/^[0-9]+$/.test(segment)) return ':number';
|
||||
if (/^[0-9a-f]{8}-[0-9a-f-]{27,}$/i.test(segment)) return ':uuid';
|
||||
if (/^[0-9a-f]{16,}$/i.test(segment)) return ':opaque';
|
||||
return segment;
|
||||
})
|
||||
.join('/');
|
||||
return [
|
||||
candidate.method.toUpperCase(),
|
||||
normalizedPath,
|
||||
[...parsed.searchParams.keys()].sort().join(','),
|
||||
candidate.resourceType,
|
||||
].join(' ');
|
||||
} catch {
|
||||
return `${candidate.method.toUpperCase()} ${candidate.path} ${candidate.resourceType}`;
|
||||
}
|
||||
}
|
||||
|
||||
function newestComparableAuthorizationPair(
|
||||
left: BrowserAuthorizationBaselineCandidate[],
|
||||
right: BrowserAuthorizationBaselineCandidate[],
|
||||
): { left: BrowserAuthorizationBaselineCandidate; right: BrowserAuthorizationBaselineCandidate } | undefined {
|
||||
const eligibleLeft = left.filter((item) => item.eligible);
|
||||
const eligibleRight = right.filter((item) => item.eligible);
|
||||
const pairs = eligibleLeft.flatMap((leftItem) => eligibleRight
|
||||
.filter((rightItem) => authorizationCandidateRoute(leftItem) === authorizationCandidateRoute(rightItem))
|
||||
.map((rightItem) => ({
|
||||
left: leftItem,
|
||||
right: rightItem,
|
||||
recency: Math.min(leftItem.startedAt, rightItem.startedAt),
|
||||
})));
|
||||
return pairs.sort((a, b) => b.recency - a.recency)[0];
|
||||
}
|
||||
|
||||
export function AuthorizationTestingWorkspace({
|
||||
state,
|
||||
setState,
|
||||
tabs,
|
||||
activeTab,
|
||||
bridge,
|
||||
refreshTabs,
|
||||
run,
|
||||
busy,
|
||||
}: AuthorizationTestingWorkspaceProps) {
|
||||
const eligibleTabs = useMemo(
|
||||
() => tabs.filter((item) => item.url.startsWith('http://') || item.url.startsWith('https://')),
|
||||
[tabs],
|
||||
);
|
||||
const [hydrated, setHydrated] = useState(false);
|
||||
const [ui, dispatch] = useReducer(
|
||||
authorizationWorkspaceUIReducer,
|
||||
INITIAL_AUTHORIZATION_WORKSPACE_UI,
|
||||
);
|
||||
const {
|
||||
mode,
|
||||
leftTabId,
|
||||
rightTabId,
|
||||
leftLabel,
|
||||
rightLabel,
|
||||
inspection,
|
||||
workspace,
|
||||
candidates,
|
||||
selected,
|
||||
capture,
|
||||
selectedPlanCandidateId,
|
||||
canaryPaths,
|
||||
} = ui;
|
||||
const [localError, setLocalError] = useState('');
|
||||
const [identityNotice, setIdentityNotice] = useState('');
|
||||
const [clock, setClock] = useState(Date.now());
|
||||
|
||||
const leftTab = eligibleTabs.find((item) => item.id === leftTabId);
|
||||
const rightTab = eligibleTabs.find((item) => item.id === rightTabId);
|
||||
const leftContext = contextForTab(inspection, leftTabId);
|
||||
const rightContext = contextForTab(inspection, rightTabId);
|
||||
const leftIsolationContextId = leftContext?.contextId || leftTab?.isolationContextId;
|
||||
const rightIsolationContextId = rightContext?.contextId || rightTab?.isolationContextId;
|
||||
const identityContextsSeparated = Boolean(
|
||||
leftIsolationContextId
|
||||
&& rightIsolationContextId
|
||||
&& leftIsolationContextId !== rightIsolationContextId,
|
||||
);
|
||||
const sameOrigin = Boolean(leftTab && rightTab && tabOrigin(leftTab) === tabOrigin(rightTab));
|
||||
const capabilityReady = bridge.state === 'connected'
|
||||
&& Boolean(bridge.capabilities?.includes('yakit.browser_authorization.task'));
|
||||
|
||||
const refreshInspection = useCallback(async () => {
|
||||
const next = await request('isolation.inspect', {
|
||||
tabIds: eligibleTabs.length > 0 ? eligibleTabs.map((item) => item.id) : undefined,
|
||||
});
|
||||
dispatch({ type: 'patch', value: { inspection: next } });
|
||||
}, [eligibleTabs]);
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const stored = await browser.storage.session.get(SESSION_KEY);
|
||||
dispatch({ type: 'hydrate', value: stored[SESSION_KEY] });
|
||||
} catch {
|
||||
// Session persistence is an ergonomic optimization.
|
||||
} finally {
|
||||
setHydrated(true);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydrated || workspace) return;
|
||||
const normalized = normalizeAuthorizationIdentityTabSelection({
|
||||
eligibleTabIds: eligibleTabs.map((item) => item.id),
|
||||
activeTabId: activeTab?.id,
|
||||
leftTabId,
|
||||
rightTabId,
|
||||
});
|
||||
if (normalized.leftTabId !== leftTabId || normalized.rightTabId !== rightTabId) {
|
||||
dispatch({
|
||||
type: 'patch',
|
||||
value: {
|
||||
leftTabId: normalized.leftTabId,
|
||||
rightTabId: normalized.rightTabId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [activeTab?.id, eligibleTabs, hydrated, leftTabId, rightTabId, workspace]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydrated) return;
|
||||
const value = persistedAuthorizationWorkspaceUI(ui);
|
||||
void browser.storage.session.set({ [SESSION_KEY]: value }).catch(() => undefined);
|
||||
}, [
|
||||
canaryPaths, candidates, hydrated, leftLabel, leftTabId, mode, rightLabel, rightTabId,
|
||||
selected, selectedPlanCandidateId, workspace,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshInspection().catch((error) => setLocalError(errorMessage(error)));
|
||||
}, [refreshInspection]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydrated || workspace || !leftTab || !rightTab) return;
|
||||
const reason = authorizationIdentityOptionDisabledReason({
|
||||
candidateTabId: rightTab.id,
|
||||
candidateIsolationContextId: rightIsolationContextId,
|
||||
otherTabId: leftTab.id,
|
||||
otherIsolationContextId: leftIsolationContextId,
|
||||
otherLabel: '身份 A',
|
||||
});
|
||||
if (!reason) return;
|
||||
dispatch({ type: 'patch', value: { rightTabId: undefined } });
|
||||
setIdentityNotice(
|
||||
leftTab.id === rightTab.id
|
||||
? '身份 B 已清空:同一个页面不能同时代表两个身份'
|
||||
: '身份 B 已清空:该页面与身份 A 共享同一登录态',
|
||||
);
|
||||
}, [
|
||||
hydrated,
|
||||
leftIsolationContextId,
|
||||
leftTab?.id,
|
||||
rightIsolationContextId,
|
||||
rightTab?.id,
|
||||
workspace,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!workspace) return;
|
||||
void Promise.all((['left', 'right'] as const).map(async (side) => {
|
||||
const target = workspace[side].target;
|
||||
const status = await request('network.capture.status', target);
|
||||
dispatch({ type: 'capture.update', side, status });
|
||||
})).catch(() => undefined);
|
||||
}, [workspace?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
const listener = (message: unknown) => {
|
||||
const input = message as { action?: string; payload?: { tabId?: number } };
|
||||
if (input?.action !== 'network.capture.changed') return;
|
||||
const side = input.payload?.tabId === workspace?.left.target.tabId
|
||||
? 'left'
|
||||
: input.payload?.tabId === workspace?.right.target.tabId ? 'right' : undefined;
|
||||
if (!side || !workspace) return;
|
||||
void request('network.capture.status', workspace[side].target)
|
||||
.then((status) => dispatch({ type: 'capture.update', side, status }))
|
||||
.catch(() => undefined);
|
||||
};
|
||||
browser.runtime.onMessage.addListener(listener);
|
||||
return () => browser.runtime.onMessage.removeListener(listener);
|
||||
}, [workspace]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!workspace) return undefined;
|
||||
setClock(Date.now());
|
||||
const timer = globalThis.setInterval(() => setClock(Date.now()), 30_000);
|
||||
return () => globalThis.clearInterval(timer);
|
||||
}, [workspace?.id]);
|
||||
|
||||
const resetWorkspace = async () => {
|
||||
dispatch({ type: 'workspace.reset' });
|
||||
setLocalError('');
|
||||
await browser.storage.session.remove(SESSION_KEY).catch(() => undefined);
|
||||
};
|
||||
|
||||
const assignIdentityTab = (side: BrowserAuthorizationSide, nextTabId: number | undefined) => {
|
||||
setLocalError('');
|
||||
setIdentityNotice('');
|
||||
dispatch({
|
||||
type: 'patch',
|
||||
value: side === 'left' ? { leftTabId: nextTabId } : { rightTabId: nextTabId },
|
||||
});
|
||||
};
|
||||
|
||||
const openIncognitoSettings = () => run(async () => {
|
||||
await browser.tabs.create({ url: `chrome://extensions/?id=${browser.runtime.id}` });
|
||||
}, '已打开扩展详情,请开启“允许在无痕模式下运行”');
|
||||
|
||||
const recheckIsolationCapability = () => run(async () => {
|
||||
await refreshTabs();
|
||||
await refreshInspection();
|
||||
}, '浏览器隔离能力已重新检测');
|
||||
|
||||
const createIsolatedIdentity = () => run(async () => {
|
||||
if (!leftTab) throw new Error('请先选择身份 A 的页面');
|
||||
const result = inspection?.browser === 'firefox'
|
||||
? await request('isolation.container.open', { url: leftTab.url, name: rightLabel || '账号 B' })
|
||||
: await request('isolation.incognito.open', { url: leftTab.url });
|
||||
await refreshTabs();
|
||||
dispatch({ type: 'patch', value: { rightTabId: result.tab.id } });
|
||||
await refreshInspection();
|
||||
}, inspection?.browser === 'firefox' ? '已创建独立 Container,请在新页面登录身份 B' : '已打开无痕身份页面,请在新页面登录身份 B');
|
||||
|
||||
const prepareWorkspace = () => run(async () => {
|
||||
setLocalError('');
|
||||
if (!leftTab || !rightTab) throw new Error('请选择身份 A 和身份 B 的页面');
|
||||
if (leftTab.id === rightTab.id) throw new Error('A/B 身份不能使用同一个标签页');
|
||||
if (!sameOrigin) throw new Error('A/B 页面必须属于同一站点 Origin');
|
||||
if (!capabilityReady) throw new Error('当前 Yak 引擎不支持插件授权测试任务,请更新并重新连接引擎');
|
||||
|
||||
const nextState = await request('grant.create', authorizationShareGrantInput(state, [leftTab, rightTab]));
|
||||
setState(nextState);
|
||||
const nextWorkspace = await runBrowserAuthorizationTask<BrowserAuthorizationWorkspace>(
|
||||
'authorization.workspace.create',
|
||||
{
|
||||
mode,
|
||||
left: { tabId: leftTab.id, frameId: 0, accountLabel: leftLabel.trim() || '账号 A' },
|
||||
right: { tabId: rightTab.id, frameId: 0, accountLabel: rightLabel.trim() || '账号 B' },
|
||||
},
|
||||
);
|
||||
dispatch({ type: 'workspace.initialize', workspace: nextWorkspace });
|
||||
if (nextWorkspace.state === 'ready' || nextWorkspace.state === 'conditional') {
|
||||
const [leftStatus, rightStatus] = await Promise.all([
|
||||
request('network.capture.start', {
|
||||
...nextWorkspace.left.target,
|
||||
captureHeaders: true,
|
||||
captureBody: true,
|
||||
maxEntries: 200,
|
||||
maxBodyBytes: 64 * 1024,
|
||||
}),
|
||||
request('network.capture.start', {
|
||||
...nextWorkspace.right.target,
|
||||
captureHeaders: true,
|
||||
captureBody: true,
|
||||
maxEntries: 200,
|
||||
maxBodyBytes: 64 * 1024,
|
||||
}),
|
||||
]);
|
||||
dispatch({ type: 'capture.replace', capture: { left: leftStatus, right: rightStatus } });
|
||||
}
|
||||
}, 'A/B 身份已验证,双方请求捕获已开始');
|
||||
|
||||
const refreshWorkspaceDocuments = async (): Promise<BrowserAuthorizationWorkspace> => {
|
||||
if (!workspace || !leftTab || !rightTab) throw new Error('请先建立 A/B 工作区');
|
||||
const nextState = await request('grant.refresh');
|
||||
setState(nextState);
|
||||
const grant = nextState.activeGrant;
|
||||
const leftTarget = grant?.targets.find((target) => (
|
||||
target.tabId === workspace.left.target.tabId
|
||||
&& target.frameId === workspace.left.target.frameId
|
||||
));
|
||||
const rightTarget = grant?.targets.find((target) => (
|
||||
target.tabId === workspace.right.target.tabId
|
||||
&& target.frameId === workspace.right.target.frameId
|
||||
));
|
||||
if (!leftTarget || !rightTarget) {
|
||||
throw new Error('当前共享会话已不再包含身份 A/B,请重新建立工作区');
|
||||
}
|
||||
const documentChanged = (
|
||||
leftTarget.documentId !== workspace.left.target.documentId
|
||||
|| rightTarget.documentId !== workspace.right.target.documentId
|
||||
);
|
||||
if (!documentChanged && workspace.expiresAt > Date.now()) return workspace;
|
||||
|
||||
const renewed = await runBrowserAuthorizationTask<BrowserAuthorizationWorkspace>(
|
||||
'authorization.workspace.create',
|
||||
{
|
||||
mode: workspace.mode,
|
||||
left: {
|
||||
tabId: leftTab.id,
|
||||
frameId: 0,
|
||||
accountLabel: workspace.left.accountLabel || leftLabel.trim() || '账号 A',
|
||||
},
|
||||
right: {
|
||||
tabId: rightTab.id,
|
||||
frameId: 0,
|
||||
accountLabel: workspace.right.accountLabel || rightLabel.trim() || '账号 B',
|
||||
},
|
||||
},
|
||||
);
|
||||
dispatch({ type: 'workspace.initialize', workspace: renewed });
|
||||
const [leftStatus, rightStatus] = await Promise.all([
|
||||
request('network.capture.status', renewed.left.target),
|
||||
request('network.capture.status', renewed.right.target),
|
||||
]);
|
||||
dispatch({ type: 'capture.replace', capture: { left: leftStatus, right: rightStatus } });
|
||||
return renewed;
|
||||
};
|
||||
|
||||
const refreshCandidates = () => run(async () => {
|
||||
const currentWorkspace = await refreshWorkspaceDocuments();
|
||||
const [left, right] = await Promise.all([
|
||||
runBrowserAuthorizationTask<BrowserAuthorizationBaselineCandidate[]>(
|
||||
'authorization.baseline.candidates',
|
||||
{ workspaceId: currentWorkspace.id, side: 'left', limit: 50 },
|
||||
),
|
||||
runBrowserAuthorizationTask<BrowserAuthorizationBaselineCandidate[]>(
|
||||
'authorization.baseline.candidates',
|
||||
{ workspaceId: currentWorkspace.id, side: 'right', limit: 50 },
|
||||
),
|
||||
]);
|
||||
dispatch({
|
||||
type: 'baselines.loaded',
|
||||
candidates: { left, right },
|
||||
selected: {
|
||||
left: left.some((item) => item.id === selected.left)
|
||||
? selected.left
|
||||
: left.find((item) => item.eligible)?.id || '',
|
||||
right: right.some((item) => item.id === selected.right)
|
||||
? selected.right
|
||||
: right.find((item) => item.eligible)?.id || '',
|
||||
},
|
||||
});
|
||||
}, mode === 'horizontal' ? '已读取双方请求,请确认它们属于同一业务动作' : '已读取低权限控制请求与高权限目标动作');
|
||||
|
||||
const bindBaselines = () => run(async () => {
|
||||
if (!workspace || !selected.left || !selected.right) throw new Error('请为 A/B 双方各选择一条正常请求');
|
||||
let next = await runBrowserAuthorizationTask<BrowserAuthorizationWorkspace>(
|
||||
'authorization.baseline.bind',
|
||||
{ workspaceId: workspace.id, side: 'left', networkRequestId: selected.left },
|
||||
);
|
||||
next = await runBrowserAuthorizationTask<BrowserAuthorizationWorkspace>(
|
||||
'authorization.baseline.bind',
|
||||
{ workspaceId: workspace.id, side: 'right', networkRequestId: selected.right },
|
||||
);
|
||||
const suggested = next.mode === 'horizontal'
|
||||
? next.baselinePair.resourceCandidates.find((item) => !item.requiresLogicalBinding)
|
||||
: next.baselinePair.operationCandidates.find((item) => item.eligible && !item.requiresDynamicRebuild);
|
||||
dispatch({
|
||||
type: 'baselines.bound',
|
||||
workspace: next,
|
||||
selectedPlanCandidateId: suggested?.id || '',
|
||||
});
|
||||
}, '双方正常请求已封存为授权基线');
|
||||
|
||||
const autoAnalyzeBaselines = () => run(async () => {
|
||||
const currentWorkspace = await refreshWorkspaceDocuments();
|
||||
const [leftCandidates, rightCandidates] = await Promise.all([
|
||||
runBrowserAuthorizationTask<BrowserAuthorizationBaselineCandidate[]>(
|
||||
'authorization.baseline.candidates',
|
||||
{ workspaceId: currentWorkspace.id, side: 'left', limit: 50 },
|
||||
),
|
||||
runBrowserAuthorizationTask<BrowserAuthorizationBaselineCandidate[]>(
|
||||
'authorization.baseline.candidates',
|
||||
{ workspaceId: currentWorkspace.id, side: 'right', limit: 50 },
|
||||
),
|
||||
]);
|
||||
const pair = mode === 'horizontal'
|
||||
? newestComparableAuthorizationPair(leftCandidates, rightCandidates)
|
||||
: {
|
||||
left: leftCandidates.find((item) => item.eligible),
|
||||
right: rightCandidates.find((item) => item.eligible),
|
||||
};
|
||||
if (!pair?.left || !pair.right) {
|
||||
throw new Error(mode === 'horizontal'
|
||||
? '还没有发现 A/B 双方可比较的同类操作。请分别执行一次相同业务动作后重试。'
|
||||
: '还没有同时发现低权限控制请求与高权限目标动作。请在 A/B 页面各执行一次后重试。');
|
||||
}
|
||||
dispatch({
|
||||
type: 'baselines.loaded',
|
||||
candidates: { left: leftCandidates, right: rightCandidates },
|
||||
selected: { left: pair.left.id, right: pair.right.id },
|
||||
});
|
||||
let next = await runBrowserAuthorizationTask<BrowserAuthorizationWorkspace>(
|
||||
'authorization.baseline.bind',
|
||||
{ workspaceId: currentWorkspace.id, side: 'left', networkRequestId: pair.left.id },
|
||||
);
|
||||
next = await runBrowserAuthorizationTask<BrowserAuthorizationWorkspace>(
|
||||
'authorization.baseline.bind',
|
||||
{ workspaceId: currentWorkspace.id, side: 'right', networkRequestId: pair.right.id },
|
||||
);
|
||||
const suggested = next.mode === 'horizontal'
|
||||
? next.baselinePair.resourceCandidates.find((item) => !item.requiresLogicalBinding)
|
||||
: next.baselinePair.operationCandidates.find((item) => item.eligible && !item.requiresDynamicRebuild);
|
||||
dispatch({
|
||||
type: 'baselines.bound',
|
||||
workspace: next,
|
||||
selectedPlanCandidateId: suggested?.id || '',
|
||||
});
|
||||
if (next.baselinePair.state !== 'matched') {
|
||||
throw new Error(`最新两项操作不可比较:${next.baselinePair.reasons[0] || '业务路由或请求结构不同'}`);
|
||||
}
|
||||
}, mode === 'horizontal'
|
||||
? '已自动找到并绑定双方最近一次同类业务操作'
|
||||
: '已自动绑定低权限控制请求与高权限目标动作');
|
||||
|
||||
const createPlan = () => run(async () => {
|
||||
if (!workspace || !selectedPlanCandidateId) throw new Error('请选择测试目标');
|
||||
const next = await runBrowserAuthorizationTask<BrowserAuthorizationWorkspace>(
|
||||
'authorization.plan.create',
|
||||
{
|
||||
workspaceId: workspace.id,
|
||||
candidateId: selectedPlanCandidateId,
|
||||
canaryPaths: canaryPaths.split(',').map((item) => item.trim()).filter(Boolean),
|
||||
},
|
||||
);
|
||||
dispatch({ type: 'workspace.updated', workspace: next });
|
||||
}, '确定性测试计划已生成,请先审阅再执行');
|
||||
|
||||
const executePlan = () => run(async () => {
|
||||
if (!workspace?.plan) throw new Error('请先生成测试计划');
|
||||
if (workspace.plan.state === 'blocked') throw new Error('当前计划被阻止,请根据原因补充证据');
|
||||
const sideEffect = workspace.plan.cases.some((item) => item.sideEffect);
|
||||
const approved = window.confirm(
|
||||
`${workspace.mode === 'vertical' ? '垂直' : '水平'}授权测试将发送 ${workspace.plan.requestBudget} 个真实请求`
|
||||
+ `${sideEffect ? ',其中包含可能改变业务状态的请求' : ''}。仅应对你有权测试的目标继续。`,
|
||||
);
|
||||
if (!approved) return;
|
||||
const next = await runBrowserAuthorizationTask<BrowserAuthorizationWorkspace>(
|
||||
'authorization.plan.execute',
|
||||
{
|
||||
workspaceId: workspace.id,
|
||||
planId: workspace.plan.id,
|
||||
approveSideEffects: sideEffect,
|
||||
},
|
||||
120_000,
|
||||
);
|
||||
dispatch({ type: 'workspace.updated', workspace: next });
|
||||
}, '授权测试矩阵执行完成');
|
||||
|
||||
const stopCapture = (side: BrowserAuthorizationSide) => run(async () => {
|
||||
if (!workspace) return;
|
||||
const status = await request('network.capture.stop', {
|
||||
tabId: workspace[side].target.tabId,
|
||||
frameId: workspace[side].target.frameId,
|
||||
});
|
||||
dispatch({ type: 'capture.update', side, status });
|
||||
}, `${side === 'left' ? leftLabel : rightLabel} 的请求捕获已停止`);
|
||||
|
||||
const refreshWorkspace = () => run(async () => {
|
||||
if (!workspace) return;
|
||||
const currentWorkspace = await refreshWorkspaceDocuments();
|
||||
const next = await runBrowserAuthorizationTask<BrowserAuthorizationWorkspace>(
|
||||
'authorization.workspace.inspect',
|
||||
{ workspaceId: currentWorkspace.id, revalidate: true },
|
||||
);
|
||||
dispatch({ type: 'workspace.updated', workspace: next });
|
||||
}, '工作区状态已复核');
|
||||
|
||||
const planCandidates = workspace?.mode === 'horizontal'
|
||||
? workspace.baselinePair.resourceCandidates
|
||||
: workspace?.baselinePair.operationCandidates;
|
||||
const executionCopy = workspace?.execution
|
||||
? verdictCopy(workspace.execution.verdict, workspace.mode)
|
||||
: undefined;
|
||||
const incognitoAccessDenied = inspection?.browser === 'chromium'
|
||||
&& inspection.capabilities.incognitoAccess === 'denied';
|
||||
const firefoxContainerUnavailable = inspection?.browser === 'firefox'
|
||||
&& !inspection.capabilities.containerTabs;
|
||||
const identityStageReady = Boolean(
|
||||
leftTab && rightTab && sameOrigin && identityContextsSeparated && capabilityReady,
|
||||
);
|
||||
const prepareHint = !leftTab
|
||||
? '先选择当前登录页作为身份 A'
|
||||
: !rightTab
|
||||
? '还需要一个隔离登录的身份 B'
|
||||
: !sameOrigin
|
||||
? 'A/B 页面必须属于同一站点'
|
||||
: !leftIsolationContextId || !rightIsolationContextId
|
||||
? '正在确认两个页面的登录态边界'
|
||||
: !identityContextsSeparated
|
||||
? 'A/B 页面仍然共享同一登录态'
|
||||
: !capabilityReady
|
||||
? '请先连接支持授权测试的 Yak 引擎'
|
||||
: '两个身份页面已就绪';
|
||||
|
||||
return <div className="section-view authorization-workspace">
|
||||
<div className="page-heading authorization-heading">
|
||||
<div>
|
||||
<span className="page-eyebrow">Browser-native authorization testing</span>
|
||||
<h1>授权测试工作区</h1>
|
||||
<p>从已经登录的两个页面建立身份隔离证明,录制双方正常请求,再由 Yak 生成并执行最小交叉矩阵。</p>
|
||||
</div>
|
||||
<div className="authorization-heading-actions">
|
||||
<span className={`authorization-engine-state ${capabilityReady ? 'ready' : ''}`}>
|
||||
<i />{capabilityReady ? '引擎可用' : '引擎能力不可用'}
|
||||
</span>
|
||||
{workspace && <span
|
||||
className="authorization-workspace-lifetime"
|
||||
title={`引擎实例 ${workspace.engineInstanceId} · 到期时间 ${new Date(workspace.expiresAt).toLocaleString()}`}
|
||||
>
|
||||
工作区剩余 {formatWorkspaceRemaining(workspace.expiresAt, clock)}
|
||||
</span>}
|
||||
{workspace && <Button variant="ghost" disabled={busy} onClick={() => void refreshWorkspace()}>
|
||||
<RefreshCw size={15} />复核状态
|
||||
</Button>}
|
||||
{workspace && bridge.capabilities?.includes('yakit.browser_authorization.open') && <Button
|
||||
variant="ghost"
|
||||
disabled={busy}
|
||||
onClick={() => void run(
|
||||
async () => { await request('authorization.yakit.open', { workspaceId: workspace.id }); },
|
||||
'已在 Yakit 打开完整证据工作区',
|
||||
)}
|
||||
>
|
||||
<ExternalLink size={15} />在 Yakit 深入分析
|
||||
</Button>}
|
||||
<Button variant="ghost" disabled={busy} onClick={() => void resetWorkspace()}>
|
||||
<RotateCcw size={15} />新建
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{localError && <div className="authorization-inline-error">
|
||||
<AlertTriangle size={16} />{localError}
|
||||
<Button size="sm" variant="ghost" onClick={() => setLocalError('')}>关闭</Button>
|
||||
</div>}
|
||||
|
||||
<div className="authorization-flow-strip" aria-label="授权测试步骤">
|
||||
{[
|
||||
['1', '身份与隔离', Boolean(workspace)],
|
||||
['2', '正常请求', Boolean(workspace?.baselines.left && workspace?.baselines.right)],
|
||||
['3', '确定性计划', Boolean(workspace?.plan)],
|
||||
['4', '结果证据', Boolean(workspace?.execution)],
|
||||
].map(([index, label, complete], position) => <div className={complete ? 'complete' : ''} key={String(label)}>
|
||||
<span>{complete ? <Check size={13} /> : index}</span><strong>{label}</strong>
|
||||
{position < 3 && <ArrowRight size={14} />}
|
||||
</div>)}
|
||||
</div>
|
||||
|
||||
{!workspace ? <section className="authorization-identity-stage">
|
||||
<div className="authorization-mode">
|
||||
<span>测试类型</span>
|
||||
<div role="radiogroup" aria-label="测试类型">
|
||||
<button type="button" role="radio" aria-checked={mode === 'horizontal'} className={mode === 'horizontal' ? 'active' : ''} onClick={() => dispatch({ type: 'patch', value: { mode: 'horizontal' } })}>
|
||||
<strong>水平越权</strong>
|
||||
</button>
|
||||
<button type="button" role="radio" aria-checked={mode === 'vertical'} className={mode === 'vertical' ? 'active' : ''} onClick={() => dispatch({ type: 'patch', value: { mode: 'vertical' } })}>
|
||||
<strong>垂直越权</strong>
|
||||
</button>
|
||||
</div>
|
||||
<small className="authorization-mode-description">{mode === 'horizontal'
|
||||
? '同权限不同账号,交换资源标识'
|
||||
: '低权限身份尝试高权限业务动作'}</small>
|
||||
</div>
|
||||
|
||||
<div className="authorization-identity-guide" aria-label="准备两个身份">
|
||||
<span className={leftTab ? 'complete' : 'current'}><b>{leftTab ? <Check size={12} /> : '1'}</b>当前登录页作为 A</span>
|
||||
<ArrowRight size={14} />
|
||||
<span className={rightTab ? 'complete' : leftTab ? 'current' : ''}><b>{rightTab ? <Check size={12} /> : '2'}</b>隔离页面登录 B</span>
|
||||
<ArrowRight size={14} />
|
||||
<span className={identityStageReady ? 'complete' : ''}><b>{identityStageReady ? <Check size={12} /> : '3'}</b>验证并开始捕获</span>
|
||||
</div>
|
||||
|
||||
<div className="authorization-identity-rail">
|
||||
<IdentitySlot
|
||||
side="A"
|
||||
title={mode === 'vertical' ? '低权限身份' : '身份 A'}
|
||||
label={leftLabel}
|
||||
setLabel={(value) => dispatch({ type: 'patch', value: { leftLabel: value } })}
|
||||
tabId={leftTabId}
|
||||
setTabId={(value) => assignIdentityTab('left', value)}
|
||||
tabs={eligibleTabs}
|
||||
context={leftContext}
|
||||
disabledReason={(item) => authorizationIdentityOptionDisabledReason({
|
||||
candidateTabId: item.id,
|
||||
candidateIsolationContextId: contextForTab(inspection, item.id)?.contextId || item.isolationContextId,
|
||||
otherTabId: rightTabId,
|
||||
otherIsolationContextId: rightIsolationContextId,
|
||||
otherLabel: '身份 B',
|
||||
})}
|
||||
emptyHint="选择你现在已经登录的页面,作为基准身份 A"
|
||||
/>
|
||||
<div className="authorization-isolation-axis" aria-live="polite">
|
||||
<Fingerprint size={23} />
|
||||
<strong>{incognitoAccessDenied ? '需要无痕权限' : !leftTab ? '先准备身份 A' : !rightTab ? '再准备身份 B' : '浏览器隔离'}</strong>
|
||||
<span className={sameOrigin ? 'valid' : ''}>{sameOrigin ? '已是同一站点' : leftTab ? 'B 需打开同一站点' : '选择当前登录页'}</span>
|
||||
<span>{identityContextsSeparated ? '浏览上下文已分离' : rightTab ? '等待隔离验证' : 'A/B 不能共用登录态'}</span>
|
||||
{incognitoAccessDenied ? <div className="authorization-isolation-actions">
|
||||
<Button size="sm" variant="secondary" disabled={busy} onClick={() => void openIncognitoSettings()}>
|
||||
<ExternalLink size={14} />开启无痕权限
|
||||
</Button>
|
||||
<button type="button" disabled={busy} onClick={() => void recheckIsolationCapability()}>已开启,重新检测</button>
|
||||
</div> : <Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
disabled={busy || !leftTab || !inspection || firefoxContainerUnavailable}
|
||||
onClick={() => void createIsolatedIdentity()}
|
||||
>
|
||||
<UserRoundPlus size={14} />{!inspection
|
||||
? '正在检测隔离能力'
|
||||
: inspection.browser === 'firefox'
|
||||
? `${rightTab ? '重新创建' : '创建'} Container 身份 B`
|
||||
: `${rightTab ? '重新创建' : '创建'}无痕身份 B`}
|
||||
</Button>}
|
||||
</div>
|
||||
<IdentitySlot
|
||||
side="B"
|
||||
title={mode === 'vertical' ? '高权限身份' : '身份 B'}
|
||||
label={rightLabel}
|
||||
setLabel={(value) => dispatch({ type: 'patch', value: { rightLabel: value } })}
|
||||
tabId={rightTabId}
|
||||
setTabId={(value) => assignIdentityTab('right', value)}
|
||||
tabs={eligibleTabs}
|
||||
context={rightContext}
|
||||
disabledReason={(item) => authorizationIdentityOptionDisabledReason({
|
||||
candidateTabId: item.id,
|
||||
candidateIsolationContextId: contextForTab(inspection, item.id)?.contextId || item.isolationContextId,
|
||||
otherTabId: leftTabId,
|
||||
otherIsolationContextId: leftIsolationContextId,
|
||||
otherLabel: '身份 A',
|
||||
})}
|
||||
emptyHint={identityNotice || '在中间创建隔离页面,登录另一个账号后会自动选为身份 B'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="authorization-prepare-bar">
|
||||
<div>
|
||||
<LockKeyhole size={18} />
|
||||
<span><strong>原始 Cookie、Storage 与请求值不会进入界面</strong><small>Yak 只接收短时上下文句柄、字段指纹和用户选择的真实请求。</small></span>
|
||||
</div>
|
||||
<div className="authorization-prepare-action">
|
||||
<small>{prepareHint}</small>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={busy || !identityStageReady}
|
||||
onClick={() => void prepareWorkspace()}
|
||||
>
|
||||
<Fingerprint size={16} />验证身份并开始捕获
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section> : <>
|
||||
<section className={`authorization-proof-band ${workspace.state}`}>
|
||||
<div>
|
||||
{workspace.proof.level === 'strong' ? <CircleCheck size={20} /> : <ShieldAlert size={20} />}
|
||||
<span><strong>{proofLabel(workspace)}</strong><small>{workspace.proof.reasons[0] || '身份隔离证明已建立'}</small></span>
|
||||
</div>
|
||||
<dl>
|
||||
<div><dt>Origin</dt><dd>{workspace.proof.sameOrigin ? '一致' : '不一致'}</dd></div>
|
||||
<div><dt>Cookie Store</dt><dd>{relationLabel(workspace.proof.cookieStoreRelation)}</dd></div>
|
||||
<div><dt>账号证据</dt><dd>{relationLabel(workspace.proof.accountEvidenceRelation)}</dd></div>
|
||||
<div><dt>请求认证</dt><dd>{relationLabel(workspace.proof.requestCredentialRelation)}</dd></div>
|
||||
<div><dt>刷新复核</dt><dd>{workspace.proof.refreshCheck === 'passed'
|
||||
? '通过'
|
||||
: workspace.proof.refreshCheck === 'not-required' ? '无需' : '失败'}</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
{workspace.state === 'stale' || workspace.state === 'blocked' ? <section className="authorization-recovery">
|
||||
<ShieldAlert size={20} />
|
||||
<div><strong>{workspace.state === 'stale' ? '工作区已经失效' : '当前身份隔离不足'}</strong><p>{workspace.recovery?.message || workspace.staleReason || workspace.proof.reasons.join(';')}</p></div>
|
||||
<Button variant="primary" onClick={() => void resetWorkspace()}>重新选择身份</Button>
|
||||
</section> : <>
|
||||
<section className="authorization-baseline-stage">
|
||||
<div className="authorization-section-heading">
|
||||
<div><span>STEP 02</span><h2>执行目标动作,插件自动识别</h2><p>{mode === 'horizontal'
|
||||
? '分别在 A/B 页面执行一次相同业务动作;插件会从最近请求中自动配对同一路由,不需要手工挑四项矩阵。'
|
||||
: '在 A 页面执行低权限正常动作,在 B 页面执行目标高权限动作;插件会自动封存最近样本。'}</p></div>
|
||||
<Button variant="primary" disabled={busy} onClick={() => void autoAnalyzeBaselines()}>
|
||||
<RefreshCw size={15} />自动分析最新操作
|
||||
</Button>
|
||||
</div>
|
||||
<div className="authorization-baseline-lanes">
|
||||
{(['left', 'right'] as const).map((side) => {
|
||||
const slot = workspace[side];
|
||||
const sideCandidates = candidates[side];
|
||||
const sideCapture = capture[side];
|
||||
return <div className="authorization-baseline-lane" key={side}>
|
||||
<header>
|
||||
<span>{side === 'left' ? 'A' : 'B'}</span>
|
||||
<div><strong>{slot.accountLabel || (side === 'left' ? leftLabel : rightLabel)}</strong><small>{authenticationStatusLabel(slot.authentication.status)} · {shortHost(side === 'left' ? leftTab : rightTab)}</small></div>
|
||||
<span className={`authorization-capture-dot ${sideCapture?.active ? 'active' : ''}`}>
|
||||
<i />{sideCapture?.active ? `${sideCapture.count} 条` : '已停止'}
|
||||
</span>
|
||||
{sideCapture?.active && <Button size="icon" variant="ghost" title="停止捕获" onClick={() => void stopCapture(side)}><Square size={14} /></Button>}
|
||||
</header>
|
||||
{sideCandidates.length === 0 ? <div className="authorization-candidate-empty">
|
||||
<Play size={17} /><span>回到该页面执行一次业务动作,再点击上方“自动分析最新操作”。</span>
|
||||
</div> : <div className="authorization-candidate-list">
|
||||
{sideCandidates.slice(0, 8).map((candidate) => <label className={`${selected[side] === candidate.id ? 'selected' : ''} ${candidate.eligible ? '' : 'disabled'}`} key={candidate.id}>
|
||||
<input
|
||||
type="radio"
|
||||
name={`authorization-${side}-candidate`}
|
||||
checked={selected[side] === candidate.id}
|
||||
disabled={!candidate.eligible}
|
||||
onChange={() => dispatch({
|
||||
type: 'patch',
|
||||
value: { selected: { ...selected, [side]: candidate.id } },
|
||||
})}
|
||||
/>
|
||||
<span><strong>{candidateLabel(candidate)}</strong><small>{candidate.eligible ? new URL(candidate.url).host : candidate.reasons[0]}</small></span>
|
||||
</label>)}
|
||||
</div>}
|
||||
</div>;
|
||||
})}
|
||||
</div>
|
||||
<div className="authorization-baseline-confirm">
|
||||
<span>{selected.left && selected.right ? '如需调整,可在上方手动选择其他请求' : '自动识别失败时,可展开候选手动选择'}</span>
|
||||
<Button variant="secondary" disabled={busy || !selected.left || !selected.right} onClick={() => void bindBaselines()}>
|
||||
<Check size={15} />使用当前选择
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{workspace.baselinePair.state !== 'waiting' && <section className="authorization-plan-stage">
|
||||
<div className="authorization-section-heading">
|
||||
<div><span>STEP 03</span><h2>{mode === 'horizontal' ? '选择资源边界' : '选择高权限动作'}</h2><p>{workspace.baselinePair.reasons[0]}</p></div>
|
||||
<span className={`authorization-pair-state ${workspace.baselinePair.state}`}>{workspace.baselinePair.state === 'matched' ? '基线已匹配' : '基线不匹配'}</span>
|
||||
</div>
|
||||
{workspace.baselinePair.state === 'matched' && planCandidates && planCandidates.length > 0 ? <div className="authorization-plan-layout">
|
||||
<div className="authorization-plan-candidates">
|
||||
{planCandidates.map((candidate) => {
|
||||
const blocked = 'requiresLogicalBinding' in candidate
|
||||
? candidate.requiresLogicalBinding
|
||||
: !candidate.eligible || candidate.requiresDynamicRebuild;
|
||||
const title = 'location' in candidate
|
||||
? `${candidate.location}.${candidate.path}`
|
||||
: `${candidate.method} ${candidate.path}`;
|
||||
const meta = 'confidence' in candidate
|
||||
? `${candidate.source === 'logical' ? '明文逻辑字段' : '线上字段'} · ${candidate.confidence}`
|
||||
: `${candidate.sideEffect ? '可能有副作用' : '只读候选'}${candidate.requiresDynamicRebuild ? ' · 需要动态重建' : ''}`;
|
||||
return <button
|
||||
key={candidate.id}
|
||||
className={selectedPlanCandidateId === candidate.id ? 'selected' : ''}
|
||||
disabled={blocked}
|
||||
onClick={() => dispatch({
|
||||
type: 'patch',
|
||||
value: { selectedPlanCandidateId: candidate.id },
|
||||
})}
|
||||
>
|
||||
<span className="authorization-radio-mark" />
|
||||
<span><strong>{title}</strong><small>{meta}</small><em>{candidate.reasons[0]}</em></span>
|
||||
{blocked && <span className="authorization-advanced-label">需明文网关</span>}
|
||||
</button>;
|
||||
})}
|
||||
</div>
|
||||
<div className="authorization-plan-review">
|
||||
<label><span>响应语义路径 <small>可选,逗号分隔</small></span><input value={canaryPaths} onChange={(event) => dispatch({ type: 'patch', value: { canaryPaths: event.target.value } })} placeholder="data.owner.id, data.account" /></label>
|
||||
{!workspace.plan ? <div className="authorization-plan-placeholder">
|
||||
<LockKeyhole size={19} /><strong>先编译,后发送</strong><p>Yak 会固定请求预算、交叉方向和只允许替换的字段,不由 UI 临时拼接请求。</p>
|
||||
</div> : <div className={`authorization-plan-summary ${workspace.plan.state}`}>
|
||||
<strong>{workspace.plan.state === 'blocked' ? '计划被阻止' : `${workspace.plan.requestBudget} 个真实请求`}</strong>
|
||||
<span>{workspace.plan.cases.map((item) => item.label).join(' → ')}</span>
|
||||
<small>{workspace.plan.reasons[0]}</small>
|
||||
</div>}
|
||||
<div className="authorization-plan-actions">
|
||||
<Button disabled={busy || !selectedPlanCandidateId} onClick={() => void createPlan()}>生成测试计划</Button>
|
||||
<Button variant="primary" disabled={busy || !workspace.plan || workspace.plan.state === 'blocked'} onClick={() => void executePlan()}>
|
||||
<Play size={15} />审阅并执行
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div> : workspace.baselinePair.state === 'matched' ? <div className="authorization-no-candidates">
|
||||
<ShieldAlert size={20} /><div><strong>没有可直接执行的确定性候选</strong><p>当前请求可能使用加密 Body、签名或动态字段。请先在“网络活动 → 明文网关”建立转换证据,再回到这里刷新工作区。</p></div>
|
||||
<a href="#network"><ExternalLink size={14} />打开明文网关</a>
|
||||
</div> : <div className="authorization-no-candidates">
|
||||
<AlertTriangle size={20} /><div><strong>A/B 不是同一类业务请求</strong><p>{workspace.baselinePair.reasons.join(';')}</p></div>
|
||||
</div>}
|
||||
</section>}
|
||||
|
||||
{workspace.execution && executionCopy && <section className={`authorization-result ${executionCopy.tone}`}>
|
||||
<header>
|
||||
<div><Fingerprint size={23} /><span><strong>{executionCopy.title}</strong><small>{executionCopy.detail}</small></span></div>
|
||||
<div><strong>{confidenceLabel(workspace.execution.confidence)}</strong><small>证据置信度</small></div>
|
||||
</header>
|
||||
<div className="authorization-result-cases">
|
||||
{workspace.execution.cases.map((item, index) => <div key={item.id}>
|
||||
<span>{String(index + 1).padStart(2, '0')}</span>
|
||||
<div><strong>{item.label}</strong><small>{item.result ? `${item.result.status} ${item.result.statusText} · ${compactDuration(item.result.durationMs)}` : item.error || authorizationOutcomeLabel(item.state)}</small></div>
|
||||
<em className={item.result?.outcome || item.state}>{authorizationOutcomeLabel(item.result?.outcome || item.state)}</em>
|
||||
</div>)}
|
||||
</div>
|
||||
{workspace.execution.reasons.length > 0 && <p>{workspace.execution.reasons.join(';')}</p>}
|
||||
{workspace.execution.evidenceAvailable && <AuthorizationEvidenceWorkbench
|
||||
workspace={workspace}
|
||||
onWorkspaceChange={(next) => dispatch({ type: 'workspace.updated', workspace: next })}
|
||||
/>}
|
||||
</section>}
|
||||
</>}
|
||||
</>}
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { ActiveTabInfo, BrowserIsolationContext } from '@/types/models';
|
||||
|
||||
function shortPageAddress(tab: ActiveTabInfo): string {
|
||||
try {
|
||||
const parsed = new URL(tab.url);
|
||||
return `${parsed.host}${parsed.pathname}${parsed.search}`;
|
||||
} catch {
|
||||
return tab.url;
|
||||
}
|
||||
}
|
||||
|
||||
function contextKindLabel(
|
||||
context: BrowserIsolationContext | undefined,
|
||||
selectedTab: ActiveTabInfo | undefined,
|
||||
): string {
|
||||
if (!selectedTab) return '等待选择页面';
|
||||
switch (context?.kind) {
|
||||
case 'chrome-incognito-store': return '无痕隔离上下文';
|
||||
case 'firefox-container':
|
||||
return context.containerName ? `Container · ${context.containerName}` : 'Container 隔离上下文';
|
||||
case 'managed-ephemeral-profile': return '独立浏览器 Profile';
|
||||
case 'verified-tab-local': return '标签页局部上下文';
|
||||
case 'sequential-auth-snapshot': return '顺序身份快照';
|
||||
default: return selectedTab.incognito ? '无痕浏览上下文' : '普通浏览上下文';
|
||||
}
|
||||
}
|
||||
|
||||
function windowKindLabel(tab: ActiveTabInfo): string {
|
||||
return tab.incognito ? '无痕窗口' : '普通窗口';
|
||||
}
|
||||
|
||||
export function IdentitySlot({
|
||||
side, title, label, setLabel, tabId, setTabId, tabs, context, disabledReason, emptyHint,
|
||||
}: {
|
||||
side: 'A' | 'B';
|
||||
title: string;
|
||||
label: string;
|
||||
setLabel: (value: string) => void;
|
||||
tabId?: number;
|
||||
setTabId: (value: number | undefined) => void;
|
||||
tabs: ActiveTabInfo[];
|
||||
context?: BrowserIsolationContext;
|
||||
disabledReason: (tab: ActiveTabInfo) => string | undefined;
|
||||
emptyHint: string;
|
||||
}) {
|
||||
const selectedTab = tabs.find((item) => item.id === tabId);
|
||||
return <div className={`authorization-identity-slot ${selectedTab ? 'is-selected' : 'is-empty'}`}>
|
||||
<header><span>{side}</span><div><strong>{title}</strong><small>{contextKindLabel(context, selectedTab)}</small></div></header>
|
||||
<label><span>账号备注</span><input value={label} maxLength={80} onChange={(event) => setLabel(event.target.value)} placeholder={side === 'A' ? '例如:普通用户' : '例如:另一个用户'} /></label>
|
||||
<label><span>{side === 'A' ? '当前已登录页面' : '另一个已登录页面'}</span><select
|
||||
aria-label={`身份 ${side} 的已登录页面`}
|
||||
value={selectedTab?.id || ''}
|
||||
onChange={(event) => setTabId(event.target.value ? Number(event.target.value) : undefined)}
|
||||
>
|
||||
<option value="">{side === 'A' ? '选择当前登录页面' : '选择页面,或在中间创建隔离身份'}</option>
|
||||
{tabs.map((item) => {
|
||||
const reason = disabledReason(item);
|
||||
return <option value={item.id} key={item.id} disabled={Boolean(reason)}>
|
||||
{item.title} · {shortPageAddress(item)} · {windowKindLabel(item)}{reason ? ` · ${reason}` : ''}
|
||||
</option>;
|
||||
})}
|
||||
</select></label>
|
||||
<div className="authorization-identity-meta">
|
||||
<span><i className={context?.level || ''} />{selectedTab
|
||||
? context?.level === 'strong'
|
||||
? '强隔离上下文'
|
||||
: context?.level === 'conditional'
|
||||
? '条件隔离上下文'
|
||||
: '隔离待验证'
|
||||
: '尚未选择页面'}</span>
|
||||
<code title={selectedTab?.url || emptyHint}>
|
||||
{selectedTab ? `${windowKindLabel(selectedTab)} · ${selectedTab.url}` : emptyHint}
|
||||
</code>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,96 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
authorizationIdentityOptionDisabledReason,
|
||||
normalizeAuthorizationIdentityTabSelection,
|
||||
} from './identity-selection';
|
||||
|
||||
describe('normalizeAuthorizationIdentityTabSelection', () => {
|
||||
it('moves the only surviving persisted page to identity A', () => {
|
||||
expect(normalizeAuthorizationIdentityTabSelection({
|
||||
eligibleTabIds: [22],
|
||||
activeTabId: 22,
|
||||
leftTabId: 11,
|
||||
rightTabId: 22,
|
||||
})).toEqual({
|
||||
leftTabId: 22,
|
||||
rightTabId: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('clears stale selections without visually falling back to another page', () => {
|
||||
expect(normalizeAuthorizationIdentityTabSelection({
|
||||
eligibleTabIds: [],
|
||||
leftTabId: 11,
|
||||
rightTabId: 22,
|
||||
})).toEqual({
|
||||
leftTabId: undefined,
|
||||
rightTabId: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps two different valid user selections', () => {
|
||||
expect(normalizeAuthorizationIdentityTabSelection({
|
||||
eligibleTabIds: [11, 22],
|
||||
activeTabId: 22,
|
||||
leftTabId: 11,
|
||||
rightTabId: 22,
|
||||
})).toEqual({
|
||||
leftTabId: 11,
|
||||
rightTabId: 22,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the active page for A while preserving a different B page', () => {
|
||||
expect(normalizeAuthorizationIdentityTabSelection({
|
||||
eligibleTabIds: [11, 22],
|
||||
activeTabId: 11,
|
||||
leftTabId: 99,
|
||||
rightTabId: 22,
|
||||
})).toEqual({
|
||||
leftTabId: 11,
|
||||
rightTabId: 22,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not automatically treat a second ordinary tab as identity B', () => {
|
||||
expect(normalizeAuthorizationIdentityTabSelection({
|
||||
eligibleTabIds: [11, 22],
|
||||
activeTabId: 11,
|
||||
})).toEqual({
|
||||
leftTabId: 11,
|
||||
rightTabId: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('authorizationIdentityOptionDisabledReason', () => {
|
||||
it('disables the exact page already assigned to the other identity', () => {
|
||||
expect(authorizationIdentityOptionDisabledReason({
|
||||
candidateTabId: 11,
|
||||
candidateIsolationContextId: 'profile:normal',
|
||||
otherTabId: 11,
|
||||
otherIsolationContextId: 'profile:normal',
|
||||
otherLabel: '身份 A',
|
||||
})).toBe('已用于身份 A');
|
||||
});
|
||||
|
||||
it('disables another page that shares the other identity login context', () => {
|
||||
expect(authorizationIdentityOptionDisabledReason({
|
||||
candidateTabId: 22,
|
||||
candidateIsolationContextId: 'profile:normal',
|
||||
otherTabId: 11,
|
||||
otherIsolationContextId: 'profile:normal',
|
||||
otherLabel: '身份 A',
|
||||
})).toBe('与身份 A 共享登录态');
|
||||
});
|
||||
|
||||
it('keeps pages from another isolation context selectable', () => {
|
||||
expect(authorizationIdentityOptionDisabledReason({
|
||||
candidateTabId: 22,
|
||||
candidateIsolationContextId: 'profile:incognito',
|
||||
otherTabId: 11,
|
||||
otherIsolationContextId: 'profile:normal',
|
||||
otherLabel: '身份 A',
|
||||
})).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
export interface AuthorizationIdentityTabSelection {
|
||||
leftTabId?: number;
|
||||
rightTabId?: number;
|
||||
}
|
||||
|
||||
export interface NormalizeAuthorizationIdentityTabSelectionInput
|
||||
extends AuthorizationIdentityTabSelection {
|
||||
eligibleTabIds: readonly number[];
|
||||
activeTabId?: number;
|
||||
}
|
||||
|
||||
export interface AuthorizationIdentityOptionConflictInput {
|
||||
candidateTabId: number;
|
||||
candidateIsolationContextId?: string;
|
||||
otherTabId?: number;
|
||||
otherIsolationContextId?: string;
|
||||
otherLabel: string;
|
||||
}
|
||||
|
||||
export function authorizationIdentityOptionDisabledReason({
|
||||
candidateTabId,
|
||||
candidateIsolationContextId,
|
||||
otherTabId,
|
||||
otherIsolationContextId,
|
||||
otherLabel,
|
||||
}: AuthorizationIdentityOptionConflictInput): string | undefined {
|
||||
if (otherTabId !== undefined && candidateTabId === otherTabId) {
|
||||
return `已用于${otherLabel}`;
|
||||
}
|
||||
if (
|
||||
candidateIsolationContextId
|
||||
&& otherIsolationContextId
|
||||
&& candidateIsolationContextId === otherIsolationContextId
|
||||
) {
|
||||
return `与${otherLabel} 共享登录态`;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function normalizeAuthorizationIdentityTabSelection({
|
||||
eligibleTabIds,
|
||||
activeTabId,
|
||||
leftTabId,
|
||||
rightTabId,
|
||||
}: NormalizeAuthorizationIdentityTabSelectionInput): AuthorizationIdentityTabSelection {
|
||||
const available = new Set(
|
||||
eligibleTabIds.filter((tabId) => Number.isSafeInteger(tabId) && tabId > 0),
|
||||
);
|
||||
const existing = (tabId?: number): number | undefined => (
|
||||
tabId !== undefined && available.has(tabId) ? tabId : undefined
|
||||
);
|
||||
|
||||
let left = existing(leftTabId);
|
||||
let right = existing(rightTabId);
|
||||
|
||||
if (left !== undefined && left === right) right = undefined;
|
||||
|
||||
if (left === undefined) {
|
||||
left = existing(activeTabId) ?? right ?? eligibleTabIds.find((tabId) => available.has(tabId));
|
||||
if (left === right) right = undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
leftTabId: left,
|
||||
rightTabId: right,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { BrowserAuthorizationWorkspace } from '../engine';
|
||||
import {
|
||||
authorizationWorkspaceUIReducer,
|
||||
authorizationWorkspaceStage,
|
||||
INITIAL_AUTHORIZATION_WORKSPACE_UI,
|
||||
normalizePersistedAuthorizationWorkspaceUI,
|
||||
persistedAuthorizationWorkspaceUI,
|
||||
} from './workspace-reducer';
|
||||
|
||||
function fixtureWorkspace(): BrowserAuthorizationWorkspace {
|
||||
return {
|
||||
version: 1,
|
||||
id: 'workspace-1',
|
||||
engineInstanceId: 'engine-1',
|
||||
mode: 'horizontal',
|
||||
state: 'ready',
|
||||
left: {
|
||||
accountLabel: '账号 A',
|
||||
origin: 'https://example.test',
|
||||
target: { tabId: 11, frameId: 0, documentId: 'document-a' },
|
||||
authentication: { status: 'authenticated', cookieCount: 1, storageEntryCount: 0 },
|
||||
},
|
||||
right: {
|
||||
accountLabel: '账号 B',
|
||||
origin: 'https://example.test',
|
||||
target: { tabId: 22, frameId: 0, documentId: 'document-b' },
|
||||
authentication: { status: 'authenticated', cookieCount: 1, storageEntryCount: 0 },
|
||||
},
|
||||
proof: {
|
||||
level: 'strong',
|
||||
sameOrigin: true,
|
||||
cookieStoreRelation: 'different',
|
||||
accountEvidenceRelation: 'different',
|
||||
requestCredentialRelation: 'different',
|
||||
refreshCheck: 'passed',
|
||||
reasons: ['隔离成立'],
|
||||
},
|
||||
baselines: {},
|
||||
baselinePair: {
|
||||
state: 'waiting',
|
||||
reasons: ['等待正常请求'],
|
||||
resourceCandidates: [],
|
||||
operationCandidates: [],
|
||||
},
|
||||
expiresAt: Date.now() + 60_000,
|
||||
};
|
||||
}
|
||||
|
||||
describe('authorization workspace UI reducer', () => {
|
||||
it('initializes a renewed workspace and clears evidence tied to the old document', () => {
|
||||
const workspace = { id: 'renewed' } as BrowserAuthorizationWorkspace;
|
||||
const previous = {
|
||||
...INITIAL_AUTHORIZATION_WORKSPACE_UI,
|
||||
candidates: { left: [{ id: 'old-left' }], right: [{ id: 'old-right' }] } as never,
|
||||
selected: { left: 'old-left', right: 'old-right' },
|
||||
selectedPlanCandidateId: 'old-plan',
|
||||
};
|
||||
|
||||
const next = authorizationWorkspaceUIReducer(previous, {
|
||||
type: 'workspace.initialize',
|
||||
workspace,
|
||||
});
|
||||
|
||||
expect(next.workspace).toBe(workspace);
|
||||
expect(next.candidates).toEqual({ left: [], right: [] });
|
||||
expect(next.selected).toEqual({ left: '', right: '' });
|
||||
expect(next.selectedPlanCandidateId).toBe('');
|
||||
});
|
||||
|
||||
it('resets workflow evidence without discarding the selected identities', () => {
|
||||
const previous = {
|
||||
...INITIAL_AUTHORIZATION_WORKSPACE_UI,
|
||||
leftTabId: 11,
|
||||
rightTabId: 12,
|
||||
workspace: { id: 'old' } as BrowserAuthorizationWorkspace,
|
||||
capture: { left: { active: true } } as never,
|
||||
};
|
||||
const next = authorizationWorkspaceUIReducer(previous, { type: 'workspace.reset' });
|
||||
|
||||
expect(next.leftTabId).toBe(11);
|
||||
expect(next.rightTabId).toBe(12);
|
||||
expect(next.workspace).toBeUndefined();
|
||||
expect(next.capture).toEqual({});
|
||||
});
|
||||
|
||||
it('persists only durable workflow state', () => {
|
||||
const value = persistedAuthorizationWorkspaceUI({
|
||||
...INITIAL_AUTHORIZATION_WORKSPACE_UI,
|
||||
inspection: { version: 1 } as never,
|
||||
capture: { left: { active: true } } as never,
|
||||
});
|
||||
expect(value).not.toHaveProperty('inspection');
|
||||
expect(value).not.toHaveProperty('capture');
|
||||
});
|
||||
|
||||
it('fails closed when a restarted UI session contains a malformed workspace', () => {
|
||||
const next = authorizationWorkspaceUIReducer(INITIAL_AUTHORIZATION_WORKSPACE_UI, {
|
||||
type: 'hydrate',
|
||||
value: {
|
||||
mode: 'vertical',
|
||||
leftTabId: 11,
|
||||
rightTabId: 'not-a-tab',
|
||||
leftLabel: '低权限账号',
|
||||
workspace: { id: 'truncated-before-storage-write' },
|
||||
candidates: { left: [null], right: { invalid: true } },
|
||||
selected: null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(next).toMatchObject({
|
||||
mode: 'vertical',
|
||||
leftTabId: 11,
|
||||
leftLabel: '低权限账号',
|
||||
workspace: undefined,
|
||||
candidates: { left: [], right: [] },
|
||||
selected: { left: '', right: '' },
|
||||
});
|
||||
expect(next.rightTabId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('normalizes a valid persisted workflow but drops invalid candidate entries', () => {
|
||||
const workspace = {
|
||||
...fixtureWorkspace(),
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
const normalized = normalizePersistedAuthorizationWorkspaceUI({
|
||||
mode: 'horizontal',
|
||||
leftTabId: 11,
|
||||
rightTabId: 22,
|
||||
leftLabel: '账号 A',
|
||||
rightLabel: '账号 B',
|
||||
workspace,
|
||||
candidates: {
|
||||
left: [{
|
||||
id: 'left-request',
|
||||
method: 'GET',
|
||||
url: 'https://example.test/api/profile?id=1',
|
||||
path: '/api/profile',
|
||||
resourceType: 'xmlhttprequest',
|
||||
startedAt: Date.now(),
|
||||
eligible: true,
|
||||
reasons: [],
|
||||
}, { id: 'invalid-url', url: 'javascript:alert(1)' }],
|
||||
right: [],
|
||||
},
|
||||
selected: { left: 'left-request', right: '' },
|
||||
selectedPlanCandidateId: '',
|
||||
canaryPaths: 'data.owner.id',
|
||||
});
|
||||
|
||||
expect(normalized?.workspace?.id).toBe('workspace-1');
|
||||
expect(normalized?.candidates?.left).toEqual([
|
||||
expect.objectContaining({ id: 'left-request' }),
|
||||
]);
|
||||
expect(normalized?.selected?.left).toBe('left-request');
|
||||
});
|
||||
|
||||
it('models the complete identity-to-evidence workflow without losing capture state', () => {
|
||||
let current = INITIAL_AUTHORIZATION_WORKSPACE_UI;
|
||||
expect(authorizationWorkspaceStage(current)).toBe('identity');
|
||||
const initial = fixtureWorkspace();
|
||||
current = authorizationWorkspaceUIReducer(current, {
|
||||
type: 'workspace.initialize',
|
||||
workspace: initial,
|
||||
});
|
||||
current = authorizationWorkspaceUIReducer(current, {
|
||||
type: 'capture.replace',
|
||||
capture: {
|
||||
left: { active: true, count: 1 } as never,
|
||||
right: { active: true, count: 1 } as never,
|
||||
},
|
||||
});
|
||||
expect(authorizationWorkspaceStage(current)).toBe('normal-requests');
|
||||
|
||||
const baseline = {
|
||||
id: 'baseline',
|
||||
networkRequestId: 'request',
|
||||
request: {
|
||||
method: 'GET',
|
||||
url: 'https://example.test/api/profile?id=1',
|
||||
path: '/api/profile',
|
||||
contentType: 'application/json',
|
||||
actionFingerprint: 'fingerprint',
|
||||
},
|
||||
};
|
||||
const bound = {
|
||||
...initial,
|
||||
baselines: { left: { ...baseline, id: 'left' }, right: { ...baseline, id: 'right' } },
|
||||
baselinePair: {
|
||||
state: 'matched' as const,
|
||||
reasons: ['同类请求'],
|
||||
resourceCandidates: [{
|
||||
id: 'resource-id',
|
||||
source: 'wire' as const,
|
||||
location: 'query' as const,
|
||||
path: 'query.id',
|
||||
category: 'identifier',
|
||||
confidence: 'high' as const,
|
||||
requiresLogicalBinding: false,
|
||||
reasons: ['A/B 值不同'],
|
||||
}],
|
||||
operationCandidates: [],
|
||||
},
|
||||
};
|
||||
current = authorizationWorkspaceUIReducer(current, {
|
||||
type: 'baselines.loaded',
|
||||
candidates: {
|
||||
left: [{ id: 'left-request' }] as never,
|
||||
right: [{ id: 'right-request' }] as never,
|
||||
},
|
||||
selected: { left: 'left-request', right: 'right-request' },
|
||||
});
|
||||
current = authorizationWorkspaceUIReducer(current, {
|
||||
type: 'baselines.bound',
|
||||
workspace: bound,
|
||||
selectedPlanCandidateId: 'resource-id',
|
||||
});
|
||||
expect(authorizationWorkspaceStage(current)).toBe('plan');
|
||||
|
||||
const planned = {
|
||||
...bound,
|
||||
plan: {
|
||||
id: 'plan-1',
|
||||
mode: 'horizontal' as const,
|
||||
candidateId: 'resource-id',
|
||||
state: 'ready' as const,
|
||||
selector: { source: 'wire' as const, location: 'query' as const, path: 'query.id' },
|
||||
cases: [],
|
||||
requestBudget: 4,
|
||||
requiresDynamicRebuild: false,
|
||||
reasons: ['固定四项矩阵'],
|
||||
},
|
||||
};
|
||||
current = authorizationWorkspaceUIReducer(current, {
|
||||
type: 'workspace.updated',
|
||||
workspace: planned,
|
||||
});
|
||||
expect(authorizationWorkspaceStage(current)).toBe('execution');
|
||||
|
||||
current = authorizationWorkspaceUIReducer(current, {
|
||||
type: 'workspace.updated',
|
||||
workspace: {
|
||||
...planned,
|
||||
execution: {
|
||||
id: 'execution-1',
|
||||
state: 'completed',
|
||||
verdict: 'protected',
|
||||
confidence: 'high',
|
||||
requestCount: 4,
|
||||
cases: [],
|
||||
evidence: [],
|
||||
evidenceAvailable: true,
|
||||
reasons: ['交叉访问均被拒绝'],
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(authorizationWorkspaceStage(current)).toBe('evidence');
|
||||
expect(current.capture.left?.active).toBe(true);
|
||||
expect(persistedAuthorizationWorkspaceUI(current)).not.toHaveProperty('capture');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,364 @@
|
||||
import type {
|
||||
BrowserIsolationInspection,
|
||||
NetworkCaptureStatus,
|
||||
} from '@/types/models';
|
||||
import type {
|
||||
BrowserAuthorizationBaselineCandidate,
|
||||
BrowserAuthorizationMode,
|
||||
BrowserAuthorizationSide,
|
||||
BrowserAuthorizationWorkspace,
|
||||
} from '../engine';
|
||||
import { normalizeBrowserAuthorizationTaskResult } from '../protocol';
|
||||
|
||||
export const EMPTY_AUTHORIZATION_CANDIDATES: Record<
|
||||
BrowserAuthorizationSide,
|
||||
BrowserAuthorizationBaselineCandidate[]
|
||||
> = { left: [], right: [] };
|
||||
|
||||
const EMPTY_SELECTION: Record<BrowserAuthorizationSide, string> = { left: '', right: '' };
|
||||
|
||||
export interface PersistedAuthorizationWorkspaceUI {
|
||||
mode: BrowserAuthorizationMode;
|
||||
leftTabId?: number;
|
||||
rightTabId?: number;
|
||||
leftLabel: string;
|
||||
rightLabel: string;
|
||||
workspace?: BrowserAuthorizationWorkspace;
|
||||
candidates: Record<BrowserAuthorizationSide, BrowserAuthorizationBaselineCandidate[]>;
|
||||
selected: Record<BrowserAuthorizationSide, string>;
|
||||
selectedPlanCandidateId: string;
|
||||
canaryPaths: string;
|
||||
}
|
||||
|
||||
export interface AuthorizationWorkspaceUIState extends PersistedAuthorizationWorkspaceUI {
|
||||
inspection?: BrowserIsolationInspection;
|
||||
capture: Partial<Record<BrowserAuthorizationSide, NetworkCaptureStatus>>;
|
||||
}
|
||||
|
||||
export const INITIAL_AUTHORIZATION_WORKSPACE_UI: AuthorizationWorkspaceUIState = {
|
||||
mode: 'horizontal',
|
||||
leftLabel: '账号 A',
|
||||
rightLabel: '账号 B',
|
||||
candidates: EMPTY_AUTHORIZATION_CANDIDATES,
|
||||
selected: EMPTY_SELECTION,
|
||||
selectedPlanCandidateId: '',
|
||||
canaryPaths: '',
|
||||
capture: {},
|
||||
};
|
||||
|
||||
export type AuthorizationWorkspaceUIAction =
|
||||
| { type: 'hydrate'; value?: unknown }
|
||||
| { type: 'patch'; value: Partial<AuthorizationWorkspaceUIState> }
|
||||
| { type: 'workspace.initialize'; workspace: BrowserAuthorizationWorkspace }
|
||||
| { type: 'workspace.updated'; workspace: BrowserAuthorizationWorkspace }
|
||||
| { type: 'workspace.reset' }
|
||||
| {
|
||||
type: 'baselines.loaded';
|
||||
candidates: Record<BrowserAuthorizationSide, BrowserAuthorizationBaselineCandidate[]>;
|
||||
selected: Record<BrowserAuthorizationSide, string>;
|
||||
}
|
||||
| {
|
||||
type: 'baselines.bound';
|
||||
workspace: BrowserAuthorizationWorkspace;
|
||||
selectedPlanCandidateId: string;
|
||||
}
|
||||
| { type: 'capture.replace'; capture: AuthorizationWorkspaceUIState['capture'] }
|
||||
| { type: 'capture.update'; side: BrowserAuthorizationSide; status: NetworkCaptureStatus };
|
||||
|
||||
export type AuthorizationWorkspaceStage =
|
||||
| 'identity'
|
||||
| 'recovery'
|
||||
| 'normal-requests'
|
||||
| 'plan'
|
||||
| 'execution'
|
||||
| 'evidence';
|
||||
|
||||
export function authorizationWorkspaceStage(
|
||||
state: AuthorizationWorkspaceUIState,
|
||||
): AuthorizationWorkspaceStage {
|
||||
const workspace = state.workspace;
|
||||
if (!workspace) return 'identity';
|
||||
if (workspace.state === 'stale' || workspace.state === 'blocked') return 'recovery';
|
||||
if (!workspace.baselines.left || !workspace.baselines.right) return 'normal-requests';
|
||||
if (!workspace.plan) return 'plan';
|
||||
if (!workspace.execution) return 'execution';
|
||||
return 'evidence';
|
||||
}
|
||||
|
||||
function normalizedCandidates(
|
||||
value: PersistedAuthorizationWorkspaceUI['candidates'] | undefined,
|
||||
): PersistedAuthorizationWorkspaceUI['candidates'] {
|
||||
return {
|
||||
left: Array.isArray(value?.left) ? value.left : [],
|
||||
right: Array.isArray(value?.right) ? value.right : [],
|
||||
};
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> | undefined {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function stringArray(value: unknown, max = 100): boolean {
|
||||
return Array.isArray(value) && value.length <= max && value.every((item) => typeof item === 'string');
|
||||
}
|
||||
|
||||
function safeWorkspaceForUI(input: unknown): BrowserAuthorizationWorkspace | undefined {
|
||||
let workspace: BrowserAuthorizationWorkspace;
|
||||
try {
|
||||
workspace = normalizeBrowserAuthorizationTaskResult<BrowserAuthorizationWorkspace>(
|
||||
'authorization.workspace.inspect',
|
||||
input,
|
||||
);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
const value = workspace as unknown as Record<string, unknown>;
|
||||
const left = record(value.left);
|
||||
const right = record(value.right);
|
||||
const proof = record(value.proof);
|
||||
const baselines = record(value.baselines);
|
||||
const pair = record(value.baselinePair);
|
||||
const validSide = (side: Record<string, unknown> | undefined) => {
|
||||
const target = record(side?.target);
|
||||
const authentication = record(side?.authentication);
|
||||
return Boolean(side && target && authentication
|
||||
&& Number.isSafeInteger(target.tabId) && Number(target.tabId) > 0
|
||||
&& Number.isSafeInteger(target.frameId) && Number(target.frameId) >= 0
|
||||
&& typeof target.documentId === 'string' && target.documentId
|
||||
&& ['authenticated', 'unauthenticated', 'unknown'].includes(String(authentication.status))
|
||||
&& Number.isFinite(authentication.cookieCount)
|
||||
&& Number.isFinite(authentication.storageEntryCount));
|
||||
};
|
||||
if (value.version !== 1 || typeof value.id !== 'string' || !value.id
|
||||
|| typeof value.engineInstanceId !== 'string' || !value.engineInstanceId
|
||||
|| !['horizontal', 'vertical'].includes(String(value.mode))
|
||||
|| !['ready', 'conditional', 'blocked', 'stale'].includes(String(value.state))
|
||||
|| !Number.isFinite(value.expiresAt)
|
||||
|| !validSide(left) || !validSide(right) || !proof || !baselines || !pair
|
||||
|| !['strong', 'conditional', 'none'].includes(String(proof.level))
|
||||
|| typeof proof.sameOrigin !== 'boolean'
|
||||
|| !['different', 'same', 'unknown'].includes(String(proof.cookieStoreRelation))
|
||||
|| !['different', 'same', 'unknown'].includes(String(proof.accountEvidenceRelation))
|
||||
|| !['different', 'same', 'unknown'].includes(String(proof.requestCredentialRelation))
|
||||
|| !['passed', 'failed', 'not-required'].includes(String(proof.refreshCheck))
|
||||
|| !stringArray(proof.reasons)
|
||||
|| !['waiting', 'matched', 'mismatch'].includes(String(pair.state))
|
||||
|| !stringArray(pair.reasons)
|
||||
|| !Array.isArray(pair.resourceCandidates) || !Array.isArray(pair.operationCandidates)) return undefined;
|
||||
|
||||
const resourceCandidatesValid = pair.resourceCandidates.every((item) => {
|
||||
const candidate = record(item);
|
||||
return Boolean(candidate && typeof candidate.id === 'string' && candidate.id
|
||||
&& ['wire', 'logical'].includes(String(candidate.source))
|
||||
&& ['header', 'path', 'query', 'body'].includes(String(candidate.location))
|
||||
&& typeof candidate.path === 'string' && typeof candidate.category === 'string'
|
||||
&& ['high', 'medium', 'low'].includes(String(candidate.confidence))
|
||||
&& typeof candidate.requiresLogicalBinding === 'boolean'
|
||||
&& stringArray(candidate.reasons));
|
||||
});
|
||||
const operationCandidatesValid = pair.operationCandidates.every((item) => {
|
||||
const candidate = record(item);
|
||||
return Boolean(candidate && typeof candidate.id === 'string' && candidate.id
|
||||
&& typeof candidate.method === 'string' && typeof candidate.path === 'string'
|
||||
&& typeof candidate.eligible === 'boolean' && typeof candidate.sideEffect === 'boolean'
|
||||
&& typeof candidate.requiresDynamicRebuild === 'boolean'
|
||||
&& stringArray(candidate.authenticationPaths) && stringArray(candidate.dynamicPaths)
|
||||
&& stringArray(candidate.reasons));
|
||||
});
|
||||
if (!resourceCandidatesValid || !operationCandidatesValid) return undefined;
|
||||
|
||||
if (value.plan !== undefined) {
|
||||
const plan = record(value.plan);
|
||||
const selector = record(plan?.selector);
|
||||
if (!plan || !selector || typeof plan.id !== 'string' || !plan.id
|
||||
|| !['horizontal', 'vertical'].includes(String(plan.mode))
|
||||
|| typeof plan.candidateId !== 'string'
|
||||
|| !['ready', 'review-required', 'blocked'].includes(String(plan.state))
|
||||
|| typeof selector.source !== 'string' || typeof selector.location !== 'string'
|
||||
|| typeof selector.path !== 'string' || !Array.isArray(plan.cases)
|
||||
|| !Number.isSafeInteger(plan.requestBudget) || Number(plan.requestBudget) < 0
|
||||
|| typeof plan.requiresDynamicRebuild !== 'boolean' || !stringArray(plan.reasons)
|
||||
|| !plan.cases.every((item) => {
|
||||
const testCase = record(item);
|
||||
return Boolean(testCase && typeof testCase.id === 'string' && typeof testCase.label === 'string'
|
||||
&& ['left', 'right'].includes(String(testCase.authContextSide))
|
||||
&& ['left', 'right', ''].includes(String(testCase.resourceValueSide))
|
||||
&& typeof testCase.method === 'string' && typeof testCase.path === 'string'
|
||||
&& typeof testCase.sideEffect === 'boolean');
|
||||
})) return undefined;
|
||||
}
|
||||
|
||||
if (value.execution !== undefined) {
|
||||
const execution = record(value.execution);
|
||||
if (!execution || typeof execution.id !== 'string' || !execution.id
|
||||
|| !['completed', 'partial'].includes(String(execution.state))
|
||||
|| !['confirmed', 'likely', 'protected', 'inconclusive', 'invalid-controls'].includes(String(execution.verdict))
|
||||
|| !['high', 'medium', 'low', 'none'].includes(String(execution.confidence))
|
||||
|| !Number.isSafeInteger(execution.requestCount) || Number(execution.requestCount) < 0
|
||||
|| typeof execution.evidenceAvailable !== 'boolean'
|
||||
|| !Array.isArray(execution.cases) || !Array.isArray(execution.evidence)
|
||||
|| !stringArray(execution.reasons)
|
||||
|| !execution.cases.every((item) => {
|
||||
const testCase = record(item);
|
||||
const result = record(testCase?.result);
|
||||
return Boolean(testCase && typeof testCase.id === 'string' && typeof testCase.label === 'string'
|
||||
&& ['completed', 'failed', 'skipped'].includes(String(testCase.state))
|
||||
&& (!result || (Number.isFinite(result.status) && typeof result.statusText === 'string'
|
||||
&& typeof result.outcome === 'string' && Number.isFinite(result.durationMs))));
|
||||
})) return undefined;
|
||||
}
|
||||
return workspace;
|
||||
}
|
||||
|
||||
function normalizePersistedCandidate(input: unknown): BrowserAuthorizationBaselineCandidate | undefined {
|
||||
const candidate = record(input);
|
||||
if (!candidate || typeof candidate.id !== 'string' || !candidate.id
|
||||
|| typeof candidate.method !== 'string' || !candidate.method
|
||||
|| typeof candidate.url !== 'string' || typeof candidate.path !== 'string'
|
||||
|| typeof candidate.resourceType !== 'string' || !Number.isFinite(candidate.startedAt)
|
||||
|| typeof candidate.eligible !== 'boolean' || !stringArray(candidate.reasons)) return undefined;
|
||||
try {
|
||||
const parsed = new URL(candidate.url);
|
||||
if (!['http:', 'https:'].includes(parsed.protocol)) return undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
id: candidate.id.slice(0, 240),
|
||||
method: candidate.method.slice(0, 32),
|
||||
url: candidate.url.slice(0, 8_192),
|
||||
path: candidate.path.slice(0, 4_096),
|
||||
resourceType: candidate.resourceType.slice(0, 120),
|
||||
startedAt: Number(candidate.startedAt),
|
||||
completedAt: Number.isFinite(candidate.completedAt) ? Number(candidate.completedAt) : undefined,
|
||||
durationMs: Number.isFinite(candidate.durationMs) ? Number(candidate.durationMs) : undefined,
|
||||
statusCode: Number.isSafeInteger(candidate.statusCode) ? Number(candidate.statusCode) : undefined,
|
||||
error: typeof candidate.error === 'string' ? candidate.error.slice(0, 1_024) : undefined,
|
||||
eligible: candidate.eligible,
|
||||
reasons: (candidate.reasons as string[]).slice(0, 20).map((item) => item.slice(0, 1_024)),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizePersistedAuthorizationWorkspaceUI(
|
||||
input: unknown,
|
||||
): Partial<PersistedAuthorizationWorkspaceUI> | undefined {
|
||||
const value = record(input);
|
||||
if (!value) return undefined;
|
||||
const workspace = value.workspace === undefined ? undefined : safeWorkspaceForUI(value.workspace);
|
||||
const candidateInput = record(value.candidates);
|
||||
const candidates = workspace ? {
|
||||
left: (Array.isArray(candidateInput?.left) ? candidateInput.left : [])
|
||||
.slice(0, 50).map(normalizePersistedCandidate)
|
||||
.filter((item): item is BrowserAuthorizationBaselineCandidate => Boolean(item)),
|
||||
right: (Array.isArray(candidateInput?.right) ? candidateInput.right : [])
|
||||
.slice(0, 50).map(normalizePersistedCandidate)
|
||||
.filter((item): item is BrowserAuthorizationBaselineCandidate => Boolean(item)),
|
||||
} : EMPTY_AUTHORIZATION_CANDIDATES;
|
||||
const selectedInput = record(value.selected);
|
||||
const selected = {
|
||||
left: typeof selectedInput?.left === 'string'
|
||||
&& candidates.left.some((item) => item.id === selectedInput.left) ? selectedInput.left : '',
|
||||
right: typeof selectedInput?.right === 'string'
|
||||
&& candidates.right.some((item) => item.id === selectedInput.right) ? selectedInput.right : '',
|
||||
};
|
||||
return {
|
||||
mode: value.mode === 'vertical' ? 'vertical' : 'horizontal',
|
||||
leftTabId: Number.isSafeInteger(value.leftTabId) && Number(value.leftTabId) > 0 ? Number(value.leftTabId) : undefined,
|
||||
rightTabId: Number.isSafeInteger(value.rightTabId) && Number(value.rightTabId) > 0 ? Number(value.rightTabId) : undefined,
|
||||
leftLabel: typeof value.leftLabel === 'string' ? value.leftLabel.slice(0, 80) : '账号 A',
|
||||
rightLabel: typeof value.rightLabel === 'string' ? value.rightLabel.slice(0, 80) : '账号 B',
|
||||
workspace,
|
||||
candidates,
|
||||
selected,
|
||||
selectedPlanCandidateId: workspace && typeof value.selectedPlanCandidateId === 'string'
|
||||
? value.selectedPlanCandidateId.slice(0, 240)
|
||||
: '',
|
||||
canaryPaths: typeof value.canaryPaths === 'string' ? value.canaryPaths.slice(0, 4_096) : '',
|
||||
};
|
||||
}
|
||||
|
||||
export function authorizationWorkspaceUIReducer(
|
||||
state: AuthorizationWorkspaceUIState,
|
||||
action: AuthorizationWorkspaceUIAction,
|
||||
): AuthorizationWorkspaceUIState {
|
||||
switch (action.type) {
|
||||
case 'hydrate': {
|
||||
const value = normalizePersistedAuthorizationWorkspaceUI(action.value);
|
||||
if (!value) return state;
|
||||
return {
|
||||
...state,
|
||||
mode: value.mode === 'vertical' ? 'vertical' : 'horizontal',
|
||||
leftTabId: value.leftTabId,
|
||||
rightTabId: value.rightTabId,
|
||||
leftLabel: value.leftLabel || '账号 A',
|
||||
rightLabel: value.rightLabel || '账号 B',
|
||||
workspace: value.workspace,
|
||||
candidates: normalizedCandidates(value.candidates),
|
||||
selected: {
|
||||
left: value.selected?.left || '',
|
||||
right: value.selected?.right || '',
|
||||
},
|
||||
selectedPlanCandidateId: value.selectedPlanCandidateId || '',
|
||||
canaryPaths: value.canaryPaths || '',
|
||||
};
|
||||
}
|
||||
case 'patch': return { ...state, ...action.value };
|
||||
case 'workspace.initialize':
|
||||
return {
|
||||
...state,
|
||||
workspace: action.workspace,
|
||||
candidates: EMPTY_AUTHORIZATION_CANDIDATES,
|
||||
selected: EMPTY_SELECTION,
|
||||
selectedPlanCandidateId: '',
|
||||
};
|
||||
case 'workspace.updated':
|
||||
return { ...state, workspace: action.workspace };
|
||||
case 'workspace.reset':
|
||||
return {
|
||||
...state,
|
||||
workspace: undefined,
|
||||
candidates: EMPTY_AUTHORIZATION_CANDIDATES,
|
||||
selected: EMPTY_SELECTION,
|
||||
selectedPlanCandidateId: '',
|
||||
capture: {},
|
||||
};
|
||||
case 'baselines.loaded':
|
||||
return {
|
||||
...state,
|
||||
candidates: action.candidates,
|
||||
selected: action.selected,
|
||||
};
|
||||
case 'baselines.bound':
|
||||
return {
|
||||
...state,
|
||||
workspace: action.workspace,
|
||||
selectedPlanCandidateId: action.selectedPlanCandidateId,
|
||||
};
|
||||
case 'capture.replace':
|
||||
return { ...state, capture: action.capture };
|
||||
case 'capture.update':
|
||||
return {
|
||||
...state,
|
||||
capture: { ...state.capture, [action.side]: action.status },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function persistedAuthorizationWorkspaceUI(
|
||||
state: AuthorizationWorkspaceUIState,
|
||||
): PersistedAuthorizationWorkspaceUI {
|
||||
return {
|
||||
mode: state.mode,
|
||||
leftTabId: state.leftTabId,
|
||||
rightTabId: state.rightTabId,
|
||||
leftLabel: state.leftLabel,
|
||||
rightLabel: state.rightLabel,
|
||||
workspace: state.workspace,
|
||||
candidates: state.candidates,
|
||||
selected: state.selected,
|
||||
selectedPlanCandidateId: state.selectedPlanCandidateId,
|
||||
canaryPaths: state.canaryPaths,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user