🐛 fix(jvm): 加固诊断命令策略与输出脱敏

在服务端阻断只读连接中的高风险和多行诊断命令,并对诊断事件与错误消息统一脱敏,避免凭证、Authorization 和 PEM 片段泄漏。
This commit is contained in:
Syngnat
2026-04-28 09:42:41 +08:00
parent 58ee269855
commit ec2eefc9d2
11 changed files with 2005 additions and 62 deletions

View File

@@ -1,5 +1,7 @@
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { act, create } from "react-test-renderer";
import { message } from "antd";
import { beforeEach, describe, expect, it, vi } from "vitest";
import JVMDiagnosticConsole, {
@@ -33,6 +35,11 @@ const baseState = {
let mockState: any = baseState;
let registeredCompletionProvider: any = null;
let registeredDiagnosticChunkHandler: any = null;
const mockBackendApp = {
JVMListDiagnosticAuditRecords: vi.fn(),
JVMExecuteDiagnosticCommand: vi.fn(),
};
const mockMonaco = {
Range: class {
startLineNumber: number;
@@ -105,6 +112,58 @@ vi.mock("@monaco-editor/react", () => ({
},
}));
vi.mock("../../wailsjs/runtime", () => ({
EventsOn: vi.fn((_eventName: string, handler: any) => {
registeredDiagnosticChunkHandler = handler;
return vi.fn();
}),
}));
vi.mock("@ant-design/icons", () => {
const Icon = () => <span />;
return {
ClearOutlined: Icon,
HistoryOutlined: Icon,
PauseCircleOutlined: Icon,
PlayCircleOutlined: Icon,
ReloadOutlined: Icon,
RocketOutlined: Icon,
ToolOutlined: Icon,
};
});
vi.mock("antd", () => {
const passthrough = ({ children, style }: any) => <div style={style}>{children}</div>;
const Text = ({ children, style }: any) => <span style={style}>{children}</span>;
const Paragraph = ({ children, style }: any) => <p style={style}>{children}</p>;
const Title = ({ children, style }: any) => <h3 style={style}>{children}</h3>;
const Empty = ({ description }: any) => <div>{description}</div>;
Empty.PRESENTED_IMAGE_SIMPLE = "simple";
const List = ({ dataSource = [], renderItem }: any) => (
<div>{dataSource.map((item: any, index: number) => renderItem(item, index))}</div>
);
List.Item = ({ children, style }: any) => <div style={style}>{children}</div>;
const Typography = { Text, Paragraph, Title };
return {
Alert: ({ message: alertMessage, description, style }: any) => (
<div style={style}>{alertMessage}{description}</div>
),
Button: ({ children, onClick, disabled, style }: any) => <button onClick={onClick} disabled={disabled} style={style}>{children}</button>,
Card: ({ children, title, style }: any) => <section style={style}>{title}{children}</section>,
Empty,
Input: ({ value, onChange, placeholder }: any) => <input value={value} onChange={onChange} placeholder={placeholder} />,
List,
Space: passthrough,
Tag: ({ children, style }: any) => <span style={style}>{children}</span>,
Typography,
message: {
success: vi.fn(),
warning: vi.fn(),
info: vi.fn(),
},
};
});
vi.mock("../store", () => ({
useStore: (selector: (state: any) => any) => selector(mockState),
}));
@@ -112,6 +171,27 @@ vi.mock("../store", () => ({
describe("JVMDiagnosticConsole", () => {
beforeEach(() => {
registeredCompletionProvider = null;
registeredDiagnosticChunkHandler = null;
mockState = {
...baseState,
setJVMDiagnosticDraft: vi.fn(),
appendJVMDiagnosticOutput: vi.fn(),
clearJVMDiagnosticOutput: vi.fn(),
};
mockBackendApp.JVMListDiagnosticAuditRecords.mockResolvedValue({
success: true,
data: [],
});
mockBackendApp.JVMExecuteDiagnosticCommand.mockReset();
vi.mocked(message.success).mockClear();
vi.mocked(message.warning).mockClear();
vi.mocked(message.info).mockClear();
(globalThis as any).window = {
...(globalThis as any).window,
go: { app: { App: mockBackendApp } },
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
};
mockMonaco.editor.setTheme.mockClear();
mockMonaco.languages.register.mockClear();
mockMonaco.languages.registerCompletionItemProvider.mockClear();
@@ -222,9 +302,48 @@ describe("JVMDiagnosticConsole", () => {
expect(markup).toContain('data-language="jvm-diagnostic"');
});
it("uses the same styled editor shell and registers command completion before mount", () => {
it("redacts sensitive diagnostic output in the rendered console", () => {
mockState = {
...baseState,
jvmDiagnosticDrafts: {
"tab-1": {
sessionId: "session-1",
command: "watch com.foo.SecretService read '{returnObj}'",
},
},
jvmDiagnosticOutputs: {
"tab-1": [
{
sessionId: "session-1",
commandId: "cmd-1",
event: "diagnostic",
phase: "running",
content: "password=secret-token\napiKey: api-key-secret",
},
],
},
};
const markup = renderToStaticMarkup(
<JVMDiagnosticConsole
tab={{
id: "tab-1",
title: "诊断增强",
type: "jvm-diagnostic",
connectionId: "conn-1",
}}
/>,
);
expect(markup).toContain("password=********");
expect(markup).toContain("apiKey: ********");
expect(markup).not.toContain("secret-token");
expect(markup).not.toContain("api-key-secret");
});
it("uses the same styled editor shell and registers command completion before mount", () => {
mockState = {
...mockState,
jvmDiagnosticDrafts: {
"tab-1": {
sessionId: "session-1",
@@ -269,4 +388,559 @@ describe("JVMDiagnosticConsole", () => {
]),
);
});
it("redacts failed diagnostic event content before storing and alerting", async () => {
mockState = {
...mockState,
jvmDiagnosticDrafts: {
"tab-1": {
sessionId: "session-1",
command: "thread -n 5",
},
},
};
let renderer: any;
await act(async () => {
renderer = create(
<JVMDiagnosticConsole
tab={{
id: "tab-1",
title: "诊断增强",
type: "jvm-diagnostic",
connectionId: "conn-1",
}}
/>,
);
});
await act(async () => {
registeredDiagnosticChunkHandler({
tabId: "tab-1",
chunk: {
sessionId: "session-1",
commandId: "cmd-1",
event: "diagnostic",
phase: "running",
content: "PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\nabc123",
},
});
registeredDiagnosticChunkHandler({
tabId: "tab-1",
chunk: {
sessionId: "session-1",
commandId: "cmd-1",
event: "diagnostic",
phase: "failed",
content: "def456\n-----END PRIVATE KEY-----",
},
});
});
const appendedChunks = mockState.appendJVMDiagnosticOutput.mock.calls.flatMap(
(call: any[]) => call[1],
);
expect(JSON.stringify(appendedChunks)).not.toContain("abc123");
expect(JSON.stringify(appendedChunks)).not.toContain("def456");
expect(JSON.stringify(renderer.toJSON())).not.toContain("def456");
});
it("redacts successful diagnostic warning messages", async () => {
mockState = {
...mockState,
jvmDiagnosticDrafts: {
"tab-1": {
sessionId: "session-1",
command: "thread -n 5",
},
},
};
mockBackendApp.JVMExecuteDiagnosticCommand.mockResolvedValue({
success: true,
message: "api_key=query-secret",
});
await act(async () => {
create(
<JVMDiagnosticConsole
tab={{
id: "tab-1",
title: "诊断增强",
type: "jvm-diagnostic",
connectionId: "conn-1",
}}
/>,
);
});
await act(async () => {
mockEditor.addCommand.mock.calls[0][1]();
});
expect(message.warning).toHaveBeenCalledWith("api_key=********");
expect(message.warning).not.toHaveBeenCalledWith(
expect.stringContaining("query-secret"),
);
});
it("redacts successful diagnostic warning messages with the active diagnostic stream state", async () => {
mockState = {
...mockState,
jvmDiagnosticDrafts: {
"tab-1": {
sessionId: "session-1",
command: "thread -n 5",
},
},
};
let resolveCommand: (value: any) => void = () => {};
mockBackendApp.JVMExecuteDiagnosticCommand.mockReturnValue(
new Promise((resolve) => {
resolveCommand = resolve;
}),
);
await act(async () => {
create(
<JVMDiagnosticConsole
tab={{
id: "tab-1",
title: "诊断增强",
type: "jvm-diagnostic",
connectionId: "conn-1",
}}
/>,
);
});
await act(async () => {
mockEditor.addCommand.mock.calls[0][1]();
});
const executeRequest = mockBackendApp.JVMExecuteDiagnosticCommand.mock.calls[0][2];
await act(async () => {
registeredDiagnosticChunkHandler({
tabId: "tab-1",
chunk: {
sessionId: executeRequest.sessionId,
commandId: executeRequest.commandId,
event: "diagnostic",
phase: "running",
content: "PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\nabc123",
},
});
});
await act(async () => {
resolveCommand({
success: true,
message: "def456\n-----END PRIVATE KEY-----",
});
});
expect(JSON.stringify((message.warning as any).mock.calls)).not.toContain(
"def456",
);
});
it("keeps diagnostic redaction state after clearing visible output", async () => {
mockState = {
...mockState,
jvmDiagnosticDrafts: {
"tab-1": {
sessionId: "session-1",
command: "thread -n 5",
},
},
};
let renderer: any;
await act(async () => {
renderer = create(
<JVMDiagnosticConsole
tab={{
id: "tab-1",
title: "诊断增强",
type: "jvm-diagnostic",
connectionId: "conn-1",
}}
/>,
);
});
await act(async () => {
registeredDiagnosticChunkHandler({
tabId: "tab-1",
chunk: {
sessionId: "session-1",
commandId: "cmd-1",
event: "diagnostic",
phase: "running",
content: "PRIVATE_KEY=-----BEGIN PRIV",
},
});
});
const clearButton = renderer.root
.findAllByType("button")
.find((button: any) => button.children.includes("清空输出"));
await act(async () => {
clearButton.props.onClick();
});
await act(async () => {
registeredDiagnosticChunkHandler({
tabId: "tab-1",
chunk: {
sessionId: "session-1",
commandId: "cmd-1",
event: "diagnostic",
phase: "failed",
content: "ATE KEY-----\nabc123\n-----END PRIVATE KEY-----",
},
});
});
const appendedChunks = mockState.appendJVMDiagnosticOutput.mock.calls.flatMap(
(call: any[]) => call[1],
);
expect(mockState.clearJVMDiagnosticOutput).toHaveBeenCalledWith("tab-1");
expect(JSON.stringify(appendedChunks)).not.toContain("ATE KEY");
expect(JSON.stringify(appendedChunks)).not.toContain("abc123");
});
it("redacts frontend fallback errors with the active diagnostic stream state", async () => {
mockState = {
...mockState,
jvmDiagnosticDrafts: {
"tab-1": {
sessionId: "session-1",
command: "thread -n 5",
},
},
};
let rejectCommand: (error: Error) => void = () => {};
mockBackendApp.JVMExecuteDiagnosticCommand.mockReturnValue(
new Promise((_resolve, reject) => {
rejectCommand = reject;
}),
);
await act(async () => {
create(
<JVMDiagnosticConsole
tab={{
id: "tab-1",
title: "诊断增强",
type: "jvm-diagnostic",
connectionId: "conn-1",
}}
/>,
);
});
await act(async () => {
mockEditor.addCommand.mock.calls[0][1]();
});
const executeRequest = mockBackendApp.JVMExecuteDiagnosticCommand.mock.calls[0][2];
await act(async () => {
registeredDiagnosticChunkHandler({
tabId: "tab-1",
chunk: {
sessionId: executeRequest.sessionId,
commandId: executeRequest.commandId,
event: "diagnostic",
phase: "running",
content: "PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\nabc123",
},
});
});
await act(async () => {
rejectCommand(new Error("def456\n-----END PRIVATE KEY-----"));
});
const appendedChunks = mockState.appendJVMDiagnosticOutput.mock.calls.flatMap(
(call: any[]) => call[1],
);
expect(JSON.stringify(appendedChunks)).not.toContain("abc123");
expect(JSON.stringify(appendedChunks)).not.toContain("def456");
});
it("keeps diagnostic redaction state after local completion fallback", async () => {
mockState = {
...mockState,
jvmDiagnosticDrafts: {
"tab-1": {
sessionId: "session-1",
command: "thread -n 5",
},
},
};
let resolveCommand: (value: any) => void = () => {};
mockBackendApp.JVMExecuteDiagnosticCommand.mockReturnValue(
new Promise((resolve) => {
resolveCommand = resolve;
}),
);
await act(async () => {
create(
<JVMDiagnosticConsole
tab={{
id: "tab-1",
title: "诊断增强",
type: "jvm-diagnostic",
connectionId: "conn-1",
}}
/>,
);
});
await act(async () => {
mockEditor.addCommand.mock.calls[0][1]();
});
const executeRequest = mockBackendApp.JVMExecuteDiagnosticCommand.mock.calls[0][2];
await act(async () => {
registeredDiagnosticChunkHandler({
tabId: "tab-1",
chunk: {
sessionId: executeRequest.sessionId,
commandId: executeRequest.commandId,
event: "diagnostic",
phase: "running",
content: "PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\nabc123",
},
});
});
await act(async () => {
resolveCommand({ success: true });
});
await act(async () => {
registeredDiagnosticChunkHandler({
tabId: "tab-1",
chunk: {
sessionId: executeRequest.sessionId,
commandId: executeRequest.commandId,
event: "diagnostic",
phase: "completed",
content: "def456\n-----END PRIVATE KEY-----",
},
});
});
const appendedChunks = mockState.appendJVMDiagnosticOutput.mock.calls.flatMap(
(call: any[]) => call[1],
);
expect(JSON.stringify(appendedChunks)).not.toContain("abc123");
expect(JSON.stringify(appendedChunks)).not.toContain("def456");
});
it("redacts terminal-seen execute errors with the active diagnostic stream state", async () => {
mockState = {
...mockState,
jvmDiagnosticDrafts: {
"tab-1": {
sessionId: "session-1",
command: "thread -n 5",
},
},
};
let rejectCommand: (error: Error) => void = () => {};
mockBackendApp.JVMExecuteDiagnosticCommand.mockReturnValue(
new Promise((_resolve, reject) => {
rejectCommand = reject;
}),
);
let renderer: any;
await act(async () => {
renderer = create(
<JVMDiagnosticConsole
tab={{
id: "tab-1",
title: "诊断增强",
type: "jvm-diagnostic",
connectionId: "conn-1",
}}
/>,
);
});
await act(async () => {
mockEditor.addCommand.mock.calls[0][1]();
});
const executeRequest = mockBackendApp.JVMExecuteDiagnosticCommand.mock.calls[0][2];
await act(async () => {
registeredDiagnosticChunkHandler({
tabId: "tab-1",
chunk: {
sessionId: executeRequest.sessionId,
commandId: executeRequest.commandId,
event: "diagnostic",
phase: "running",
content: "PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\nabc123",
},
});
registeredDiagnosticChunkHandler({
tabId: "tab-1",
chunk: {
sessionId: executeRequest.sessionId,
commandId: executeRequest.commandId,
event: "diagnostic",
phase: "completed",
content: "still waiting for execute call",
},
});
});
await act(async () => {
rejectCommand(new Error("def456\n-----END PRIVATE KEY-----"));
});
expect(JSON.stringify(renderer.toJSON())).not.toContain("def456");
});
it("redacts execute errors after a real failed terminal event closes the active PEM stream", async () => {
mockState = {
...mockState,
jvmDiagnosticDrafts: {
"tab-1": {
sessionId: "session-1",
command: "thread -n 5",
},
},
};
let rejectCommand: (error: Error) => void = () => {};
mockBackendApp.JVMExecuteDiagnosticCommand.mockReturnValue(
new Promise((_resolve, reject) => {
rejectCommand = reject;
}),
);
let renderer: any;
await act(async () => {
renderer = create(
<JVMDiagnosticConsole
tab={{
id: "tab-1",
title: "诊断增强",
type: "jvm-diagnostic",
connectionId: "conn-1",
}}
/>,
);
});
await act(async () => {
mockEditor.addCommand.mock.calls[0][1]();
});
const executeRequest = mockBackendApp.JVMExecuteDiagnosticCommand.mock.calls[0][2];
await act(async () => {
registeredDiagnosticChunkHandler({
tabId: "tab-1",
chunk: {
sessionId: executeRequest.sessionId,
commandId: executeRequest.commandId,
event: "diagnostic",
phase: "running",
content: "PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\nabc123",
},
});
registeredDiagnosticChunkHandler({
tabId: "tab-1",
chunk: {
sessionId: executeRequest.sessionId,
commandId: executeRequest.commandId,
event: "diagnostic",
phase: "failed",
content: "def456\n-----END PRIVATE KEY-----",
},
});
});
await act(async () => {
rejectCommand(new Error("def456\n-----END PRIVATE KEY-----"));
});
expect(JSON.stringify(renderer.toJSON())).not.toContain("def456");
});
it("redacts delayed failed terminal events after frontend fallback closes the active PEM stream", async () => {
mockState = {
...mockState,
jvmDiagnosticDrafts: {
"tab-1": {
sessionId: "session-1",
command: "thread -n 5",
},
},
};
let rejectCommand: (error: Error) => void = () => {};
mockBackendApp.JVMExecuteDiagnosticCommand.mockReturnValue(
new Promise((_resolve, reject) => {
rejectCommand = reject;
}),
);
await act(async () => {
create(
<JVMDiagnosticConsole
tab={{
id: "tab-1",
title: "诊断增强",
type: "jvm-diagnostic",
connectionId: "conn-1",
}}
/>,
);
});
await act(async () => {
mockEditor.addCommand.mock.calls[0][1]();
});
const executeRequest = mockBackendApp.JVMExecuteDiagnosticCommand.mock.calls[0][2];
await act(async () => {
registeredDiagnosticChunkHandler({
tabId: "tab-1",
chunk: {
sessionId: executeRequest.sessionId,
commandId: executeRequest.commandId,
event: "diagnostic",
phase: "running",
content: "PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\nabc123",
},
});
});
await act(async () => {
rejectCommand(new Error("def456\n-----END PRIVATE KEY-----"));
});
await act(async () => {
registeredDiagnosticChunkHandler({
tabId: "tab-1",
chunk: {
sessionId: executeRequest.sessionId,
commandId: executeRequest.commandId,
event: "diagnostic",
phase: "failed",
content: "def456\n-----END PRIVATE KEY-----",
},
});
});
const appendedChunks = mockState.appendJVMDiagnosticOutput.mock.calls.flatMap(
(call: any[]) => call[1],
);
expect(JSON.stringify(appendedChunks)).not.toContain("abc123");
expect(JSON.stringify(appendedChunks)).not.toContain("def456");
});
});

View File

@@ -33,8 +33,12 @@ import type {
import { buildRpcConnectionConfig } from "../utils/connectionRpcConfig";
import { resolveJVMDiagnosticCompletionItems } from "../utils/jvmDiagnosticCompletion";
import {
createJVMDiagnosticRedactionState,
formatJVMDiagnosticTransportLabel,
JVM_DIAGNOSTIC_COMMAND_PRESETS,
redactJVMDiagnosticChunkContent,
redactJVMDiagnosticOutput,
type JVMDiagnosticRedactionState,
} from "../utils/jvmDiagnosticPresentation";
import JVMCommandPresetBar from "./jvm/JVMCommandPresetBar";
import JVMDiagnosticHistory from "./jvm/JVMDiagnosticHistory";
@@ -200,6 +204,10 @@ export const createJVMDiagnosticRunningRecord = ({
status: "running",
});
const buildJVMDiagnosticRedactionKey = (
chunk: Pick<JVMDiagnosticEventChunk, "sessionId" | "commandId">,
): string => `${chunk.sessionId || "unknown-session"}::${chunk.commandId || "unknown-command"}`;
const JVMDiagnosticConsole: React.FC<JVMDiagnosticConsoleProps> = ({ tab }) => {
const connection = useStore((state) =>
state.connections.find((item) => item.id === tab.connectionId),
@@ -224,6 +232,41 @@ const JVMDiagnosticConsole: React.FC<JVMDiagnosticConsoleProps> = ({ tab }) => {
const [error, setError] = useState("");
const activeCommandIdRef = useRef("");
const terminalCommandIdsRef = useRef<Set<string>>(new Set());
const redactionStatesRef = useRef<Record<string, JVMDiagnosticRedactionState>>({});
const redactDiagnosticContent = useCallback(
(
content: string,
chunk: Pick<JVMDiagnosticEventChunk, "sessionId" | "commandId">,
) => {
const key = buildJVMDiagnosticRedactionKey(chunk);
const state =
redactionStatesRef.current[key] || createJVMDiagnosticRedactionState();
redactionStatesRef.current[key] = state;
return redactJVMDiagnosticChunkContent(content, state);
},
[],
);
const redactDiagnosticChunk = useCallback(
(chunk: JVMDiagnosticEventChunk, options: { keepState?: boolean } = {}) => {
const key = buildJVMDiagnosticRedactionKey(chunk);
const safeChunk = {
...chunk,
content: redactDiagnosticContent(String(chunk.content || ""), chunk),
};
if (
!options.keepState &&
isJVMDiagnosticTerminalPhase(chunk.phase) &&
!redactionStatesRef.current[key]?.insideSensitivePem &&
!redactionStatesRef.current[key]?.sawSensitivePem
) {
delete redactionStatesRef.current[key];
}
return safeChunk;
},
[redactDiagnosticContent],
);
const finishActiveCommand = useCallback((commandId: string) => {
if (!commandId || activeCommandIdRef.current !== commandId) {
@@ -283,7 +326,7 @@ const JVMDiagnosticConsole: React.FC<JVMDiagnosticConsoleProps> = ({ tab }) => {
}
setRecords(Array.isArray(result?.data) ? result.data : []);
} catch (err: any) {
setError(err?.message || "加载诊断历史失败");
setError(redactJVMDiagnosticOutput(err?.message || "加载诊断历史失败"));
} finally {
setHistoryLoading(false);
}
@@ -332,13 +375,14 @@ const JVMDiagnosticConsole: React.FC<JVMDiagnosticConsoleProps> = ({ tab }) => {
return;
}
appendOutput(tab.id, [payload.chunk]);
if (payload.chunk.phase === "failed") {
setError(payload.chunk.content || "诊断命令执行失败");
const safeChunk = redactDiagnosticChunk(payload.chunk);
appendOutput(tab.id, [safeChunk]);
if (safeChunk.phase === "failed") {
setError(safeChunk.content || "诊断命令执行失败");
}
if (payload.chunk.commandId && isJVMDiagnosticTerminalPhase(payload.chunk.phase)) {
terminalCommandIdsRef.current.add(payload.chunk.commandId);
finishActiveCommand(payload.chunk.commandId);
if (safeChunk.commandId && isJVMDiagnosticTerminalPhase(safeChunk.phase)) {
terminalCommandIdsRef.current.add(safeChunk.commandId);
finishActiveCommand(safeChunk.commandId);
void loadAuditRecords();
}
});
@@ -348,7 +392,7 @@ const JVMDiagnosticConsole: React.FC<JVMDiagnosticConsoleProps> = ({ tab }) => {
stopListening();
}
};
}, [appendOutput, finishActiveCommand, loadAuditRecords, tab.id]);
}, [appendOutput, finishActiveCommand, loadAuditRecords, redactDiagnosticChunk, tab.id]);
const handleProbe = async () => {
if (!rpcConnectionConfig) {
@@ -372,7 +416,7 @@ const JVMDiagnosticConsole: React.FC<JVMDiagnosticConsoleProps> = ({ tab }) => {
setCapabilities(Array.isArray(result?.data) ? result.data : []);
} catch (err: any) {
setCapabilities([]);
setError(err?.message || "检查诊断能力失败");
setError(redactJVMDiagnosticOutput(err?.message || "检查诊断能力失败"));
} finally {
setLoading(false);
}
@@ -409,7 +453,7 @@ const JVMDiagnosticConsole: React.FC<JVMDiagnosticConsoleProps> = ({ tab }) => {
void loadAuditRecords();
} catch (err: any) {
setSession(null);
setError(err?.message || "创建诊断会话失败");
setError(redactJVMDiagnosticOutput(err?.message || "创建诊断会话失败"));
} finally {
setLoading(false);
}
@@ -478,22 +522,27 @@ const JVMDiagnosticConsole: React.FC<JVMDiagnosticConsoleProps> = ({ tab }) => {
throw new Error(String(result?.message || "执行诊断命令失败"));
}
if (result?.message) {
message.warning(result.message);
message.warning(
redactDiagnosticContent(String(result.message), { sessionId, commandId }),
);
}
const terminalSeen = terminalCommandIdsRef.current.has(commandId);
if (!terminalSeen) {
appendOutput(tab.id, [
{
sessionId,
commandId,
event: "diagnostic",
phase: "completed",
content: "诊断命令调用已返回,但未收到后端终态事件,前端已兜底结束等待状态。",
timestamp: Date.now(),
metadata: {
source: "frontend-fallback",
redactDiagnosticChunk(
{
sessionId,
commandId,
event: "diagnostic",
phase: "completed",
content: "诊断命令调用已返回,但未收到后端终态事件,前端已兜底结束等待状态。",
timestamp: Date.now(),
metadata: {
source: "frontend-fallback",
},
},
},
{ keepState: true },
),
]);
}
finishActiveCommand(commandId);
@@ -524,21 +573,22 @@ const JVMDiagnosticConsole: React.FC<JVMDiagnosticConsoleProps> = ({ tab }) => {
});
}
} catch (err: any) {
const messageText = err?.message || "执行诊断命令失败";
const rawMessageText = String(err?.message || "执行诊断命令失败");
let messageText = "";
if (!terminalCommandIdsRef.current.has(commandId)) {
appendOutput(tab.id, [
{
sessionId,
commandId,
event: "diagnostic",
phase: "failed",
content: messageText,
timestamp: Date.now(),
metadata: {
source: "frontend-fallback",
},
const safeChunk = redactDiagnosticChunk({
sessionId,
commandId,
event: "diagnostic",
phase: "failed",
content: rawMessageText,
timestamp: Date.now(),
metadata: {
source: "frontend-fallback",
},
]);
});
messageText = safeChunk.content;
appendOutput(tab.id, [safeChunk]);
setRecords((current) =>
current.map((record) =>
record.commandId === commandId
@@ -546,6 +596,8 @@ const JVMDiagnosticConsole: React.FC<JVMDiagnosticConsoleProps> = ({ tab }) => {
: record,
),
);
} else {
messageText = redactDiagnosticContent(rawMessageText, { sessionId, commandId });
}
finishActiveCommand(commandId);
setError(messageText);
@@ -576,7 +628,7 @@ const JVMDiagnosticConsole: React.FC<JVMDiagnosticConsoleProps> = ({ tab }) => {
}
message.info("已发送取消请求");
} catch (err: any) {
setError(err?.message || "取消诊断命令失败");
setError(redactJVMDiagnosticOutput(err?.message || "取消诊断命令失败"));
} finally {
setLoading(false);
}

View File

@@ -3,7 +3,7 @@ import { Empty, List, Tag, Typography } from "antd";
import type { JVMDiagnosticEventChunk } from "../../types";
import {
formatJVMDiagnosticChunkText,
formatJVMDiagnosticChunksForDisplay,
formatJVMDiagnosticEventLabel,
formatJVMDiagnosticPhaseLabel,
} from "../../utils/jvmDiagnosticPresentation";
@@ -28,6 +28,8 @@ const JVMDiagnosticOutput: React.FC<JVMDiagnosticOutputProps> = ({
);
}
const chunkTexts = formatJVMDiagnosticChunksForDisplay(chunks);
return (
<div style={{ maxHeight, overflow: "auto", paddingRight: 4 }}>
<List
@@ -45,7 +47,7 @@ const JVMDiagnosticOutput: React.FC<JVMDiagnosticOutputProps> = ({
fontFamily: "SFMono-Regular, Menlo, Monaco, Consolas, monospace",
}}
>
{formatJVMDiagnosticChunkText(chunk)}
{chunkTexts[index]}
</Text>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
{chunk.phase ? (

View File

@@ -2,12 +2,14 @@ import { describe, expect, it } from "vitest";
import {
formatJVMDiagnosticChunkText,
formatJVMDiagnosticChunksForDisplay,
formatJVMDiagnosticCommandTypeLabel,
formatJVMDiagnosticPhaseLabel,
formatJVMDiagnosticRiskLabel,
formatJVMDiagnosticSourceLabel,
formatJVMDiagnosticTransportLabel,
groupJVMDiagnosticPresets,
redactJVMDiagnosticOutput,
resolveJVMDiagnosticRiskColor,
} from "./jvmDiagnosticPresentation";
@@ -32,6 +34,204 @@ describe("jvmDiagnosticPresentation", () => {
).toBe("执行中thread -n 5");
});
it("redacts sensitive values in diagnostic output chunks", () => {
const text = formatJVMDiagnosticChunkText({
sessionId: "sess-1",
phase: "running",
content:
"password=secret-token\napiKey: api-key-secret\naccessToken = bearer-secret\nPRIVATE_KEY=-----BEGIN PRIVATE KEY-----raw-key",
});
expect(text).toContain("password=********");
expect(text).toContain("apiKey: ********");
expect(text).toContain("accessToken = ********");
expect(text).toContain("PRIVATE_KEY=********");
expect(text).not.toContain("secret-token");
expect(text).not.toContain("api-key-secret");
expect(text).not.toContain("bearer-secret");
expect(text).not.toContain("raw-key");
});
it("redacts JSON, environment, separator and partial PEM sensitive output", () => {
const text = redactJVMDiagnosticOutput([
'{"password":"json-secret","api_key":"api-json-secret","accessToken":"access-json-secret"}',
"DB_PASSWORD=hunter2",
"SPRING_DATASOURCE_PASSWORD=spring-secret",
"AWS_SECRET_ACCESS_KEY=aws-secret",
"api-key: kebab-secret",
"api key = spaced-secret",
"private.key: dot-secret",
"refresh_token=refresh-secret",
"secret=foo;bar",
"PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\nraw-key-line",
].join("\n"));
expect(text).toContain('"password":"********"');
expect(text).toContain('"api_key":"********"');
expect(text).toContain('"accessToken":"********"');
expect(text).toContain("DB_PASSWORD=********");
expect(text).toContain("SPRING_DATASOURCE_PASSWORD=********");
expect(text).toContain("AWS_SECRET_ACCESS_KEY=********");
expect(text).toContain("api-key: ********");
expect(text).toContain("api key = ********");
expect(text).toContain("private.key: ********");
expect(text).toContain("refresh_token=********");
expect(text).toContain("secret=********");
expect(text).toContain("PRIVATE_KEY=********");
expect(text).not.toContain("json-secret");
expect(text).not.toContain("api-json-secret");
expect(text).not.toContain("access-json-secret");
expect(text).not.toContain("hunter2");
expect(text).not.toContain("spring-secret");
expect(text).not.toContain("aws-secret");
expect(text).not.toContain("kebab-secret");
expect(text).not.toContain("spaced-secret");
expect(text).not.toContain("dot-secret");
expect(text).not.toContain("refresh-secret");
expect(text).not.toContain("foo;bar");
expect(text).not.toContain("raw-key-line");
});
it("redacts PEM continuation across diagnostic chunks", () => {
const texts = formatJVMDiagnosticChunksForDisplay([
{
sessionId: "sess-1",
phase: "running",
content: "PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\nabc123",
},
{
sessionId: "sess-1",
phase: "running",
content: "def456\n-----END PRIVATE KEY-----",
},
{
sessionId: "sess-1",
phase: "running",
content: "thread_name=main",
},
]);
expect(texts.join("\n")).not.toContain("abc123");
expect(texts.join("\n")).not.toContain("def456");
expect(texts.join("\n")).not.toContain("PRIVATE KEY");
expect(texts[2]).toContain("thread_name=main");
});
it("redacts PEM begin marker split across diagnostic chunks", () => {
const texts = formatJVMDiagnosticChunksForDisplay([
{
sessionId: "sess-1",
phase: "running",
content: "PRIVATE_KEY=-----BEGIN PRIV",
},
{
sessionId: "sess-1",
phase: "running",
content: "ATE KEY-----\nabc123\n-----END PRIVATE KEY-----",
},
{
sessionId: "sess-1",
phase: "running",
content: "thread_name=main",
},
]);
expect(texts.join("\n")).not.toContain("BEGIN PRIV");
expect(texts.join("\n")).not.toContain("ATE KEY");
expect(texts.join("\n")).not.toContain("abc123");
expect(texts[2]).toContain("thread_name=main");
});
it("redacts algorithm-prefixed PEM begin marker split across chunks", () => {
const texts = formatJVMDiagnosticChunksForDisplay([
{
sessionId: "sess-1",
phase: "running",
content: "-----BEGIN RSA PRIV",
},
{
sessionId: "sess-1",
phase: "running",
content: "ATE KEY-----\nabc123\n-----END RSA PRIVATE KEY-----",
},
{
sessionId: "sess-1",
phase: "running",
content: "thread_name=main",
},
]);
expect(texts.join("\n")).not.toContain("RSA PRIV");
expect(texts.join("\n")).not.toContain("ATE KEY");
expect(texts.join("\n")).not.toContain("abc123");
expect(texts[2]).toContain("thread_name=main");
});
it("redacts algorithm-prefixed PEM markers split after the algorithm and inside key labels", () => {
const cases = [
["-----BEGIN RSA", " PRIVATE KEY-----\nabc123\n-----END RSA PRIVATE KEY-----"],
["-----BEGIN RSA PRIVATE K", "EY-----\nabc123\n-----END RSA PRIVATE KEY-----"],
["-----BEGIN OPENSSH", " PRIVATE KEY-----\nabc123\n-----END OPENSSH PRIVATE KEY-----"],
["-----BEGIN EC PRIVATE KE", "Y-----\nabc123\n-----END EC PRIVATE KEY-----"],
];
for (const [firstChunk, secondChunk] of cases) {
const texts = formatJVMDiagnosticChunksForDisplay([
{
sessionId: "sess-1",
phase: "running",
content: firstChunk,
},
{
sessionId: "sess-1",
phase: "running",
content: secondChunk,
},
]);
expect(texts.join("\n")).not.toContain("PRIVATE K");
expect(texts.join("\n")).not.toContain("EY-----");
expect(texts.join("\n")).not.toContain("abc123");
}
});
it("redacts JSON scalar values and URL query parameters", () => {
const text = redactJVMDiagnosticOutput(
'{"password":123456,"token":true,"credential":null}\nhttps://svc.local/callback?access_token=url-secret&x=1&api_key=query-secret',
);
expect(text).toContain('"password":********');
expect(text).toContain('"token":********');
expect(text).toContain('"credential":********');
expect(text).toContain("access_token=********");
expect(text).toContain("api_key=********");
expect(text).not.toContain("123456");
expect(text).not.toContain("true");
expect(text).not.toContain("url-secret");
expect(text).not.toContain("query-secret");
});
it("redacts authorization values across text, JSON and query parameters", () => {
const text = redactJVMDiagnosticOutput(
'Authorization: Bearer header-secret\n{"authorization":"Bearer json-secret"}\nhttps://svc.local/callback?authorization=Bearer%20query-secret',
);
expect(text).toContain("Authorization: ********");
expect(text).toContain('"authorization":"********"');
expect(text).toContain("authorization=********");
expect(text).not.toContain("header-secret");
expect(text).not.toContain("json-secret");
expect(text).not.toContain("query-secret");
});
it("keeps non-sensitive diagnostic output unchanged", () => {
expect(
redactJVMDiagnosticOutput(
"thread_name=main\nmethod: com.foo.OrderService.submit\ncost=42ms",
),
).toBe("thread_name=main\nmethod: com.foo.OrderService.submit\ncost=42ms");
});
it("localizes diagnostic status, transport, risk and source labels", () => {
expect(formatJVMDiagnosticPhaseLabel("completed")).toBe("已完成");
expect(formatJVMDiagnosticTransportLabel("arthas-tunnel")).toBe("Arthas Tunnel");

View File

@@ -103,6 +103,160 @@ const SOURCE_LABELS: Record<string, string> = {
"ai-plan": "AI 计划",
};
const JVM_DIAGNOSTIC_REDACTION_MASK = "********";
const JVM_DIAGNOSTIC_SENSITIVE_KEY_PATTERN =
"(?:password|passwd|pwd|secret|token|credential|authorization|api[_.\\- \\t]*key|access[_.\\- \\t]*key|private[_.\\- \\t]*key|secret[_.\\- \\t]*key|auth[_.\\- \\t]*key|access[_.\\- \\t]*token|refresh[_.\\- \\t]*token)";
const JVM_DIAGNOSTIC_SENSITIVE_KEY_BODY =
`[A-Za-z0-9_.\\- \\t]*${JVM_DIAGNOSTIC_SENSITIVE_KEY_PATTERN}[A-Za-z0-9_.\\- \\t]*`;
const JVM_DIAGNOSTIC_PEM_BEGIN_PATTERN =
/-----BEGIN [^-]*(?:PRIVATE KEY|SECRET|TOKEN|CREDENTIAL)[^-]*-----/i;
const JVM_DIAGNOSTIC_PEM_END_PATTERN =
/-----END [^-]*(?:PRIVATE KEY|SECRET|TOKEN|CREDENTIAL)[^-]*-----/i;
const JVM_DIAGNOSTIC_PEM_BEGIN_PREFIX_PATTERN = /-----BEGIN[\s\S]*$/i;
const JVM_DIAGNOSTIC_PEM_END_CONTINUATION_PATTERN =
/^[\s\S]*?-----END [^-]*(?:PRIVATE KEY|SECRET|TOKEN|CREDENTIAL)[^-]*-----/i;
const JVM_DIAGNOSTIC_COMPLETE_PEM_PATTERN =
/-----BEGIN [^-]*(?:PRIVATE KEY|SECRET|TOKEN|CREDENTIAL)[\s\S]*?-----END [^-]*(?:PRIVATE KEY|SECRET|TOKEN|CREDENTIAL)[^-]*-----/gi;
const JVM_DIAGNOSTIC_PARTIAL_PEM_PATTERN =
/-----BEGIN [^-]*(?:PRIVATE KEY|SECRET|TOKEN|CREDENTIAL)[\s\S]*$/gi;
const JVM_DIAGNOSTIC_SENSITIVE_PEM_LABELS = [
"PRIVATE KEY",
"RSA PRIVATE KEY",
"DSA PRIVATE KEY",
"EC PRIVATE KEY",
"OPENSSH PRIVATE KEY",
"ENCRYPTED PRIVATE KEY",
"SECRET",
"TOKEN",
"CREDENTIAL",
];
const JVM_DIAGNOSTIC_DOUBLE_QUOTED_VALUE_PATTERN = new RegExp(
`(")(${JVM_DIAGNOSTIC_SENSITIVE_KEY_BODY})(")([ \\t]*:[ \\t]*)(")((?:\\\\.|[^"\\\\])*)(")`,
"gi",
);
const JVM_DIAGNOSTIC_SINGLE_QUOTED_VALUE_PATTERN = new RegExp(
`(')(${JVM_DIAGNOSTIC_SENSITIVE_KEY_BODY})(')([ \\t]*:[ \\t]*)(')((?:\\\\.|[^'\\\\])*)(')`,
"gi",
);
const JVM_DIAGNOSTIC_UNQUOTED_SCALAR_PATTERN = new RegExp(
`(["']?)(${JVM_DIAGNOSTIC_SENSITIVE_KEY_BODY})(\\1)([ \\t]*:[ \\t]*)(true|false|null|-?\\d+(?:\\.\\d+)?)`,
"gi",
);
const JVM_DIAGNOSTIC_UNQUOTED_KEY_VALUE_PATTERN = new RegExp(
`(^|[\\r\\n,;{\\[?&]|\\s)(${JVM_DIAGNOSTIC_SENSITIVE_KEY_BODY})([ \\t]*[:=][ \\t]*)([^\\r\\n&]*)`,
"gi",
);
const redactJVMDiagnosticKeyValues = (value: string): string =>
value
.replace(
JVM_DIAGNOSTIC_DOUBLE_QUOTED_VALUE_PATTERN,
(_match, keyOpen: string, key: string, keyClose: string, separator: string, valueOpen: string, _rawValue: string, valueClose: string) =>
`${keyOpen}${key}${keyClose}${separator}${valueOpen}${JVM_DIAGNOSTIC_REDACTION_MASK}${valueClose}`,
)
.replace(
JVM_DIAGNOSTIC_SINGLE_QUOTED_VALUE_PATTERN,
(_match, keyOpen: string, key: string, keyClose: string, separator: string, valueOpen: string, _rawValue: string, valueClose: string) =>
`${keyOpen}${key}${keyClose}${separator}${valueOpen}${JVM_DIAGNOSTIC_REDACTION_MASK}${valueClose}`,
)
.replace(
JVM_DIAGNOSTIC_UNQUOTED_SCALAR_PATTERN,
(_match, keyOpen: string, key: string, keyClose: string, separator: string) =>
`${keyOpen}${key}${keyClose}${separator}${JVM_DIAGNOSTIC_REDACTION_MASK}`,
)
.replace(
JVM_DIAGNOSTIC_UNQUOTED_KEY_VALUE_PATTERN,
(_match, prefix: string, key: string, separator: string) =>
`${prefix}${key}${separator}${JVM_DIAGNOSTIC_REDACTION_MASK}`,
);
export type JVMDiagnosticRedactionState = {
insideSensitivePem: boolean;
sawSensitivePem: boolean;
};
export const createJVMDiagnosticRedactionState = (): JVMDiagnosticRedactionState => ({
insideSensitivePem: false,
sawSensitivePem: false,
});
const hasSensitivePemBeginPrefix = (value: string): boolean => {
const match = value.match(JVM_DIAGNOSTIC_PEM_BEGIN_PREFIX_PATTERN);
if (!match) {
return false;
}
const prefix = match[0];
const label = prefix
.replace(/^-----BEGIN\s*/i, "")
.replace(/-+$/g, "")
.trim()
.replace(/\s+/g, " ")
.toUpperCase();
if (
!label ||
JVM_DIAGNOSTIC_SENSITIVE_PEM_LABELS.some(
(item) => item.startsWith(label) || label.startsWith(item),
)
) {
return true;
}
return new RegExp(
`${JVM_DIAGNOSTIC_SENSITIVE_KEY_BODY}[ \t]*[:=][ \t]*-----BEGIN[\\s\\S]*$`,
"i",
).test(value);
};
const redactJVMDiagnosticOutputWithState = (
value: string,
state: JVMDiagnosticRedactionState,
): string => {
let text = value;
if (state.insideSensitivePem) {
const pemEnd = text.search(JVM_DIAGNOSTIC_PEM_END_PATTERN);
if (pemEnd < 0) {
return JVM_DIAGNOSTIC_REDACTION_MASK;
}
state.insideSensitivePem = false;
state.sawSensitivePem = true;
text = `${JVM_DIAGNOSTIC_REDACTION_MASK}${text.slice(pemEnd).replace(JVM_DIAGNOSTIC_PEM_END_PATTERN, "")}`;
} else if (state.sawSensitivePem && JVM_DIAGNOSTIC_PEM_END_PATTERN.test(text)) {
text = text.replace(
JVM_DIAGNOSTIC_PEM_END_CONTINUATION_PATTERN,
JVM_DIAGNOSTIC_REDACTION_MASK,
);
}
text = text
.replace(JVM_DIAGNOSTIC_COMPLETE_PEM_PATTERN, () => {
state.sawSensitivePem = true;
return JVM_DIAGNOSTIC_REDACTION_MASK;
})
.replace(JVM_DIAGNOSTIC_PARTIAL_PEM_PATTERN, (match) => {
state.sawSensitivePem = true;
state.insideSensitivePem = !JVM_DIAGNOSTIC_PEM_END_PATTERN.test(match);
return JVM_DIAGNOSTIC_REDACTION_MASK;
});
if (!state.insideSensitivePem && hasSensitivePemBeginPrefix(text)) {
state.insideSensitivePem = true;
state.sawSensitivePem = true;
text = text.replace(
JVM_DIAGNOSTIC_PEM_BEGIN_PREFIX_PATTERN,
JVM_DIAGNOSTIC_REDACTION_MASK,
);
}
return redactJVMDiagnosticKeyValues(text);
};
export const redactJVMDiagnosticChunkContent = (
value?: string | null,
state: JVMDiagnosticRedactionState = createJVMDiagnosticRedactionState(),
): string => redactJVMDiagnosticOutputWithState(String(value || ""), state);
export const redactJVMDiagnosticOutput = (value?: string | null): string =>
redactJVMDiagnosticChunkContent(value);
export const formatJVMDiagnosticPresetCategory = (
category: JVMDiagnosticPresetCategory,
): string => CATEGORY_LABELS[category];
@@ -159,14 +313,14 @@ export const groupJVMDiagnosticPresets = (
items: presets.filter((item) => item.category === category),
}));
export const formatJVMDiagnosticChunkText = (
const formatJVMDiagnosticChunkTextWithContent = (
chunk: JVMDiagnosticEventChunk,
content: string,
): string => {
const rawPhase = String(chunk.phase || chunk.event || "").trim();
const phase = chunk.phase
? formatJVMDiagnosticPhaseLabel(chunk.phase)
: formatJVMDiagnosticEventLabel(chunk.event);
const content = String(chunk.content || "").trim();
if (!rawPhase && !content) {
return "空事件";
}
@@ -178,3 +332,23 @@ export const formatJVMDiagnosticChunkText = (
}
return `${phase}${content}`;
};
export const formatJVMDiagnosticChunkText = (
chunk: JVMDiagnosticEventChunk,
): string =>
formatJVMDiagnosticChunkTextWithContent(
chunk,
redactJVMDiagnosticOutput(chunk.content).trim(),
);
export const formatJVMDiagnosticChunksForDisplay = (
chunks: JVMDiagnosticEventChunk[],
): string[] => {
const state = createJVMDiagnosticRedactionState();
return chunks.map((chunk) =>
formatJVMDiagnosticChunkTextWithContent(
chunk,
redactJVMDiagnosticChunkContent(chunk.content, state).trim(),
),
);
};