mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-05-07 04:42:51 +08:00
🐛 fix(jvm): 加固诊断命令策略与输出脱敏
在服务端阻断只读连接中的高风险和多行诊断命令,并对诊断事件与错误消息统一脱敏,避免凭证、Authorization 和 PEM 片段泄漏。
This commit is contained in:
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
)
|
||||
|
||||
var newJVMDiagnosticTransport = jvm.NewDiagnosticTransport
|
||||
var emitJVMDiagnosticRuntimeEvent = runtime.EventsEmit
|
||||
|
||||
const diagnosticChunkEvent = "jvm:diagnostic:chunk"
|
||||
|
||||
@@ -81,6 +82,8 @@ func (a *App) JVMExecuteDiagnosticCommand(cfg connection.ConnectionConfig, tabID
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
|
||||
redactor := jvm.NewDiagnosticOutputRedactor()
|
||||
|
||||
req.SessionID = strings.TrimSpace(req.SessionID)
|
||||
req.CommandID = strings.TrimSpace(req.CommandID)
|
||||
req.Command = strings.TrimSpace(req.Command)
|
||||
@@ -100,9 +103,10 @@ func (a *App) JVMExecuteDiagnosticCommand(cfg connection.ConnectionConfig, tabID
|
||||
req.Source = "manual"
|
||||
}
|
||||
|
||||
commandType, err := jvm.ValidateDiagnosticCommandPolicy(normalized.JVM.Diagnostic, req.Command)
|
||||
commandType, err := jvm.ValidateDiagnosticExecutionPolicy(normalized, req.Command)
|
||||
if err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
message := redactor.RedactContent(req.SessionID, req.CommandID, err.Error())
|
||||
return connection.QueryResult{Success: false, Message: message}
|
||||
}
|
||||
riskLevel := diagnosticRiskLevel(commandType)
|
||||
auditStore := jvm.NewDiagnosticAuditStore(filepath.Join(a.auditRootDir(), "jvm_diag_audit.jsonl"))
|
||||
@@ -120,7 +124,7 @@ func (a *App) JVMExecuteDiagnosticCommand(cfg connection.ConnectionConfig, tabID
|
||||
RiskLevel: riskLevel,
|
||||
Status: "running",
|
||||
}); err != nil {
|
||||
auditWarnings = append(auditWarnings, "审计记录写入失败: "+err.Error())
|
||||
return connection.QueryResult{Success: false, Message: "诊断审计记录写入失败,已阻止命令执行: " + err.Error()}
|
||||
}
|
||||
|
||||
terminalSeen := false
|
||||
@@ -150,12 +154,9 @@ func (a *App) JVMExecuteDiagnosticCommand(cfg connection.ConnectionConfig, tabID
|
||||
if chunk.Timestamp == 0 {
|
||||
chunk.Timestamp = time.Now().UnixMilli()
|
||||
}
|
||||
if strings.TrimSpace(chunk.SessionID) == "" {
|
||||
chunk.SessionID = req.SessionID
|
||||
}
|
||||
if strings.TrimSpace(chunk.CommandID) == "" {
|
||||
chunk.CommandID = req.CommandID
|
||||
}
|
||||
chunk.SessionID = req.SessionID
|
||||
chunk.CommandID = req.CommandID
|
||||
chunk = redactor.RedactChunk(chunk)
|
||||
a.emitDiagnosticChunk(tabID, chunk)
|
||||
if isDiagnosticTerminalPhase(chunk.Phase) {
|
||||
appendTerminalAudit(chunk.Phase)
|
||||
@@ -168,19 +169,20 @@ func (a *App) JVMExecuteDiagnosticCommand(cfg connection.ConnectionConfig, tabID
|
||||
if strings.Contains(strings.ToLower(err.Error()), "canceled") {
|
||||
phase = "canceled"
|
||||
}
|
||||
redactedError := redactor.RedactContent(req.SessionID, req.CommandID, err.Error())
|
||||
if !terminalSeen {
|
||||
chunk := jvm.DiagnosticEventChunk{
|
||||
SessionID: req.SessionID,
|
||||
CommandID: req.CommandID,
|
||||
Event: "diagnostic",
|
||||
Phase: phase,
|
||||
Content: err.Error(),
|
||||
Content: redactedError,
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
}
|
||||
a.emitDiagnosticChunk(tabID, chunk)
|
||||
appendTerminalAudit(phase)
|
||||
}
|
||||
return connection.QueryResult{Success: false, Message: joinDiagnosticMessages(err.Error(), auditWarnings)}
|
||||
return connection.QueryResult{Success: false, Message: joinDiagnosticMessages(redactedError, auditWarnings)}
|
||||
}
|
||||
|
||||
if !terminalSeen {
|
||||
@@ -253,7 +255,7 @@ func (a *App) emitDiagnosticChunk(tabID string, chunk jvm.DiagnosticEventChunk)
|
||||
if a.ctx == nil {
|
||||
return
|
||||
}
|
||||
runtime.EventsEmit(a.ctx, diagnosticChunkEvent, diagnosticChunkEventPayload{
|
||||
emitJVMDiagnosticRuntimeEvent(a.ctx, diagnosticChunkEvent, diagnosticChunkEventPayload{
|
||||
TabID: strings.TrimSpace(tabID),
|
||||
Chunk: chunk,
|
||||
})
|
||||
|
||||
@@ -2,7 +2,10 @@ package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"GoNavi-Wails/internal/connection"
|
||||
@@ -10,16 +13,17 @@ import (
|
||||
)
|
||||
|
||||
type fakeDiagnosticTransport struct {
|
||||
testErr error
|
||||
caps []jvm.DiagnosticCapability
|
||||
capsErr error
|
||||
handle jvm.DiagnosticSessionHandle
|
||||
startErr error
|
||||
executeReq jvm.DiagnosticCommandRequest
|
||||
executeErr error
|
||||
cancelSession string
|
||||
cancelCommand string
|
||||
cancelErr error
|
||||
testErr error
|
||||
caps []jvm.DiagnosticCapability
|
||||
capsErr error
|
||||
handle jvm.DiagnosticSessionHandle
|
||||
startErr error
|
||||
executeReq jvm.DiagnosticCommandRequest
|
||||
executeErr error
|
||||
executeCalls int
|
||||
cancelSession string
|
||||
cancelCommand string
|
||||
cancelErr error
|
||||
}
|
||||
|
||||
func (f fakeDiagnosticTransport) Mode() string { return jvm.DiagnosticTransportAgentBridge }
|
||||
@@ -48,6 +52,55 @@ func (f fakeDiagnosticTransport) CloseSession(context.Context, connection.Connec
|
||||
return nil
|
||||
}
|
||||
|
||||
type fakeStreamingDiagnosticTransport struct {
|
||||
sink jvm.DiagnosticEventSink
|
||||
chunks []jvm.DiagnosticEventChunk
|
||||
executeErr error
|
||||
}
|
||||
|
||||
func (f *fakeStreamingDiagnosticTransport) Mode() string { return jvm.DiagnosticTransportAgentBridge }
|
||||
|
||||
func (f *fakeStreamingDiagnosticTransport) TestConnection(context.Context, connection.ConnectionConfig) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeStreamingDiagnosticTransport) ProbeCapabilities(context.Context, connection.ConnectionConfig) ([]jvm.DiagnosticCapability, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakeStreamingDiagnosticTransport) StartSession(context.Context, connection.ConnectionConfig, jvm.DiagnosticSessionRequest) (jvm.DiagnosticSessionHandle, error) {
|
||||
return jvm.DiagnosticSessionHandle{}, nil
|
||||
}
|
||||
|
||||
func (f *fakeStreamingDiagnosticTransport) SetEventSink(sink jvm.DiagnosticEventSink) {
|
||||
f.sink = sink
|
||||
}
|
||||
|
||||
func (f *fakeStreamingDiagnosticTransport) ExecuteCommand(context.Context, connection.ConnectionConfig, jvm.DiagnosticCommandRequest) error {
|
||||
if f.sink != nil {
|
||||
chunks := f.chunks
|
||||
if len(chunks) == 0 {
|
||||
chunks = []jvm.DiagnosticEventChunk{{
|
||||
Event: "diagnostic",
|
||||
Phase: "running",
|
||||
Content: "PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\nabc123",
|
||||
}}
|
||||
}
|
||||
for _, chunk := range chunks {
|
||||
f.sink(chunk)
|
||||
}
|
||||
}
|
||||
return f.executeErr
|
||||
}
|
||||
|
||||
func (f *fakeStreamingDiagnosticTransport) CancelCommand(context.Context, connection.ConnectionConfig, string, string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeStreamingDiagnosticTransport) CloseSession(context.Context, connection.ConnectionConfig, string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestJVMProbeDiagnosticCapabilitiesReturnsTransportPayload(t *testing.T) {
|
||||
app := NewAppWithSecretStore(nil)
|
||||
restore := swapJVMDiagnosticTransportFactory(func(mode string) (jvm.DiagnosticTransport, error) {
|
||||
@@ -161,6 +214,417 @@ func TestJVMExecuteDiagnosticCommandReturnsAccepted(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestJVMExecuteDiagnosticCommandBlocksTraceWhenConnectionReadOnly(t *testing.T) {
|
||||
app := NewAppWithSecretStore(nil)
|
||||
app.configDir = t.TempDir()
|
||||
recorder := &fakeDiagnosticTransport{}
|
||||
restore := swapJVMDiagnosticTransportFactory(func(mode string) (jvm.DiagnosticTransport, error) {
|
||||
return diagnosticTransportRecorder{recorder: recorder}, nil
|
||||
})
|
||||
defer restore()
|
||||
|
||||
readOnly := true
|
||||
res := app.JVMExecuteDiagnosticCommand(connection.ConnectionConfig{
|
||||
ID: "conn-orders",
|
||||
Type: "jvm",
|
||||
Host: "orders.internal",
|
||||
JVM: connection.JVMConfig{
|
||||
ReadOnly: &readOnly,
|
||||
Diagnostic: connection.JVMDiagnosticConfig{
|
||||
Enabled: true,
|
||||
Transport: jvm.DiagnosticTransportAgentBridge,
|
||||
BaseURL: "http://127.0.0.1:19091/gonavi/diag",
|
||||
AllowTraceCommands: true,
|
||||
},
|
||||
},
|
||||
}, "tab-diag-1", jvm.DiagnosticCommandRequest{
|
||||
SessionID: "sess-1",
|
||||
CommandID: "cmd-trace-1",
|
||||
Command: "watch com.foo.OrderService submitOrder '{params,returnObj}' -x 2",
|
||||
Source: "manual",
|
||||
Reason: "定位慢调用",
|
||||
})
|
||||
|
||||
if res.Success {
|
||||
t.Fatalf("expected trace command to be blocked in read-only mode, got %+v", res)
|
||||
}
|
||||
if !strings.Contains(res.Message, "只读") {
|
||||
t.Fatalf("expected read-only message, got %+v", res)
|
||||
}
|
||||
if recorder.executeCalls != 0 {
|
||||
t.Fatalf("expected transport ExecuteCommand not called, got %d", recorder.executeCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJVMExecuteDiagnosticCommandBlocksMutatingWhenConnectionReadOnly(t *testing.T) {
|
||||
app := NewAppWithSecretStore(nil)
|
||||
app.configDir = t.TempDir()
|
||||
recorder := &fakeDiagnosticTransport{}
|
||||
restore := swapJVMDiagnosticTransportFactory(func(mode string) (jvm.DiagnosticTransport, error) {
|
||||
return diagnosticTransportRecorder{recorder: recorder}, nil
|
||||
})
|
||||
defer restore()
|
||||
|
||||
readOnly := true
|
||||
res := app.JVMExecuteDiagnosticCommand(connection.ConnectionConfig{
|
||||
ID: "conn-orders",
|
||||
Type: "jvm",
|
||||
Host: "orders.internal",
|
||||
JVM: connection.JVMConfig{
|
||||
ReadOnly: &readOnly,
|
||||
Diagnostic: connection.JVMDiagnosticConfig{
|
||||
Enabled: true,
|
||||
Transport: jvm.DiagnosticTransportAgentBridge,
|
||||
BaseURL: "http://127.0.0.1:19091/gonavi/diag",
|
||||
AllowMutatingCommands: true,
|
||||
},
|
||||
},
|
||||
}, "tab-diag-1", jvm.DiagnosticCommandRequest{
|
||||
SessionID: "sess-1",
|
||||
CommandID: "cmd-mutating-1",
|
||||
Command: "ognl '@java.lang.System@getProperty(\"user.dir\")'",
|
||||
Source: "manual",
|
||||
Reason: "读取系统属性",
|
||||
})
|
||||
|
||||
if res.Success {
|
||||
t.Fatalf("expected mutating command to be blocked in read-only mode, got %+v", res)
|
||||
}
|
||||
if !strings.Contains(res.Message, "只读") {
|
||||
t.Fatalf("expected read-only message, got %+v", res)
|
||||
}
|
||||
if recorder.executeCalls != 0 {
|
||||
t.Fatalf("expected transport ExecuteCommand not called, got %d", recorder.executeCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJVMExecuteDiagnosticCommandBlocksMultilineCommandWhenConnectionReadOnly(t *testing.T) {
|
||||
app := NewAppWithSecretStore(nil)
|
||||
app.configDir = t.TempDir()
|
||||
recorder := &fakeDiagnosticTransport{}
|
||||
restore := swapJVMDiagnosticTransportFactory(func(mode string) (jvm.DiagnosticTransport, error) {
|
||||
return diagnosticTransportRecorder{recorder: recorder}, nil
|
||||
})
|
||||
defer restore()
|
||||
|
||||
readOnly := true
|
||||
res := app.JVMExecuteDiagnosticCommand(connection.ConnectionConfig{
|
||||
ID: "conn-orders",
|
||||
Type: "jvm",
|
||||
Host: "orders.internal",
|
||||
JVM: connection.JVMConfig{
|
||||
ReadOnly: &readOnly,
|
||||
Diagnostic: connection.JVMDiagnosticConfig{
|
||||
Enabled: true,
|
||||
Transport: jvm.DiagnosticTransportAgentBridge,
|
||||
BaseURL: "http://127.0.0.1:19091/gonavi/diag",
|
||||
AllowObserveCommands: true,
|
||||
AllowTraceCommands: true,
|
||||
AllowMutatingCommands: true,
|
||||
},
|
||||
},
|
||||
}, "tab-diag-1", jvm.DiagnosticCommandRequest{
|
||||
SessionID: "sess-1",
|
||||
CommandID: "cmd-multiline-1",
|
||||
Command: "thread -n 1\nognl '@java.lang.System@setProperty(\"x\",\"y\")'",
|
||||
Source: "manual",
|
||||
Reason: "观察线程",
|
||||
})
|
||||
|
||||
if res.Success {
|
||||
t.Fatalf("expected multiline command to be blocked in read-only mode, got %+v", res)
|
||||
}
|
||||
if recorder.executeCalls != 0 {
|
||||
t.Fatalf("expected transport ExecuteCommand not called, got %d", recorder.executeCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJVMExecuteDiagnosticCommandAllowsObserveWhenConnectionReadOnly(t *testing.T) {
|
||||
app := NewAppWithSecretStore(nil)
|
||||
app.configDir = t.TempDir()
|
||||
recorder := &fakeDiagnosticTransport{}
|
||||
restore := swapJVMDiagnosticTransportFactory(func(mode string) (jvm.DiagnosticTransport, error) {
|
||||
return diagnosticTransportRecorder{recorder: recorder}, nil
|
||||
})
|
||||
defer restore()
|
||||
|
||||
readOnly := true
|
||||
res := app.JVMExecuteDiagnosticCommand(connection.ConnectionConfig{
|
||||
ID: "conn-orders",
|
||||
Type: "jvm",
|
||||
Host: "orders.internal",
|
||||
JVM: connection.JVMConfig{
|
||||
ReadOnly: &readOnly,
|
||||
Diagnostic: connection.JVMDiagnosticConfig{
|
||||
Enabled: true,
|
||||
Transport: jvm.DiagnosticTransportAgentBridge,
|
||||
BaseURL: "http://127.0.0.1:19091/gonavi/diag",
|
||||
AllowObserveCommands: true,
|
||||
},
|
||||
},
|
||||
}, "tab-diag-1", jvm.DiagnosticCommandRequest{
|
||||
SessionID: "sess-1",
|
||||
CommandID: "cmd-observe-1",
|
||||
Command: "thread -n 5",
|
||||
Source: "manual",
|
||||
Reason: "观察线程",
|
||||
})
|
||||
|
||||
if !res.Success {
|
||||
t.Fatalf("expected observe command to be allowed in read-only mode, got %+v", res)
|
||||
}
|
||||
if recorder.executeCalls != 1 {
|
||||
t.Fatalf("expected transport ExecuteCommand called once, got %d", recorder.executeCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJVMExecuteDiagnosticCommandRedactsExecuteErrorMessage(t *testing.T) {
|
||||
app := NewAppWithSecretStore(nil)
|
||||
app.configDir = t.TempDir()
|
||||
restore := swapJVMDiagnosticTransportFactory(func(mode string) (jvm.DiagnosticTransport, error) {
|
||||
return fakeDiagnosticTransport{executeErr: errors.New("Authorization: Bearer header-secret")}, nil
|
||||
})
|
||||
defer restore()
|
||||
|
||||
res := app.JVMExecuteDiagnosticCommand(connection.ConnectionConfig{
|
||||
ID: "conn-orders",
|
||||
Type: "jvm",
|
||||
Host: "orders.internal",
|
||||
JVM: connection.JVMConfig{
|
||||
Diagnostic: connection.JVMDiagnosticConfig{
|
||||
Enabled: true,
|
||||
Transport: jvm.DiagnosticTransportAgentBridge,
|
||||
BaseURL: "http://127.0.0.1:19091/gonavi/diag",
|
||||
AllowObserveCommands: true,
|
||||
},
|
||||
},
|
||||
}, "tab-diag-1", jvm.DiagnosticCommandRequest{
|
||||
SessionID: "sess-1",
|
||||
CommandID: "cmd-observe-secret",
|
||||
Command: "thread -n 5",
|
||||
Source: "manual",
|
||||
Reason: "观察线程",
|
||||
})
|
||||
|
||||
if res.Success {
|
||||
t.Fatalf("expected execute failure, got %+v", res)
|
||||
}
|
||||
if strings.Contains(res.Message, "header-secret") {
|
||||
t.Fatalf("expected execute error message to be redacted, got %q", res.Message)
|
||||
}
|
||||
if !strings.Contains(res.Message, "Authorization: ********") {
|
||||
t.Fatalf("expected redacted authorization message, got %q", res.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJVMExecuteDiagnosticCommandRedactsExecuteErrorWithStreamingPEMState(t *testing.T) {
|
||||
app := NewAppWithSecretStore(nil)
|
||||
app.configDir = t.TempDir()
|
||||
restore := swapJVMDiagnosticTransportFactory(func(mode string) (jvm.DiagnosticTransport, error) {
|
||||
return &fakeStreamingDiagnosticTransport{executeErr: errors.New("def456\n-----END PRIVATE KEY-----")}, nil
|
||||
})
|
||||
defer restore()
|
||||
|
||||
res := app.JVMExecuteDiagnosticCommand(connection.ConnectionConfig{
|
||||
ID: "conn-orders",
|
||||
Type: "jvm",
|
||||
Host: "orders.internal",
|
||||
JVM: connection.JVMConfig{
|
||||
Diagnostic: connection.JVMDiagnosticConfig{
|
||||
Enabled: true,
|
||||
Transport: jvm.DiagnosticTransportAgentBridge,
|
||||
BaseURL: "http://127.0.0.1:19091/gonavi/diag",
|
||||
AllowObserveCommands: true,
|
||||
},
|
||||
},
|
||||
}, "tab-diag-1", jvm.DiagnosticCommandRequest{
|
||||
SessionID: "sess-1",
|
||||
CommandID: "cmd-observe-pem",
|
||||
Command: "thread -n 5",
|
||||
Source: "manual",
|
||||
Reason: "观察线程",
|
||||
})
|
||||
|
||||
if res.Success {
|
||||
t.Fatalf("expected execute failure, got %+v", res)
|
||||
}
|
||||
if strings.Contains(res.Message, "def456") || strings.Contains(res.Message, "PRIVATE KEY") {
|
||||
t.Fatalf("expected execute error PEM continuation to be redacted, got %q", res.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJVMExecuteDiagnosticCommandRedactsPolicyErrorMessage(t *testing.T) {
|
||||
app := NewAppWithSecretStore(nil)
|
||||
app.configDir = t.TempDir()
|
||||
recorder := &fakeDiagnosticTransport{}
|
||||
restore := swapJVMDiagnosticTransportFactory(func(mode string) (jvm.DiagnosticTransport, error) {
|
||||
return diagnosticTransportRecorder{recorder: recorder}, nil
|
||||
})
|
||||
defer restore()
|
||||
|
||||
res := app.JVMExecuteDiagnosticCommand(connection.ConnectionConfig{
|
||||
ID: "conn-orders",
|
||||
Type: "jvm",
|
||||
Host: "orders.internal",
|
||||
JVM: connection.JVMConfig{
|
||||
Diagnostic: connection.JVMDiagnosticConfig{
|
||||
Enabled: true,
|
||||
Transport: jvm.DiagnosticTransportAgentBridge,
|
||||
BaseURL: "http://127.0.0.1:19091/gonavi/diag",
|
||||
AllowObserveCommands: true,
|
||||
},
|
||||
},
|
||||
}, "tab-diag-1", jvm.DiagnosticCommandRequest{
|
||||
SessionID: "sess-1",
|
||||
CommandID: "cmd-policy-secret",
|
||||
Command: "watch com.foo.OrderService submitOrder '{params}' password=plain-secret",
|
||||
Source: "manual",
|
||||
Reason: "观察线程",
|
||||
})
|
||||
|
||||
if res.Success {
|
||||
t.Fatalf("expected policy failure, got %+v", res)
|
||||
}
|
||||
if strings.Contains(res.Message, "plain-secret") {
|
||||
t.Fatalf("expected policy error message to be redacted, got %q", res.Message)
|
||||
}
|
||||
if recorder.executeCalls != 0 {
|
||||
t.Fatalf("expected transport ExecuteCommand not called, got %d", recorder.executeCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJVMExecuteDiagnosticCommandEmitsRedactedChunksWithRequestIDs(t *testing.T) {
|
||||
app := NewAppWithSecretStore(nil)
|
||||
app.configDir = t.TempDir()
|
||||
app.ctx = context.Background()
|
||||
|
||||
var emitted []diagnosticChunkEventPayload
|
||||
prevEmitter := emitJVMDiagnosticRuntimeEvent
|
||||
emitJVMDiagnosticRuntimeEvent = func(ctx context.Context, eventName string, optionalData ...interface{}) {
|
||||
if eventName != diagnosticChunkEvent {
|
||||
return
|
||||
}
|
||||
payload, ok := optionalData[0].(diagnosticChunkEventPayload)
|
||||
if !ok {
|
||||
t.Fatalf("expected diagnostic chunk event payload, got %#v", optionalData[0])
|
||||
}
|
||||
emitted = append(emitted, payload)
|
||||
}
|
||||
defer func() { emitJVMDiagnosticRuntimeEvent = prevEmitter }()
|
||||
|
||||
restore := swapJVMDiagnosticTransportFactory(func(mode string) (jvm.DiagnosticTransport, error) {
|
||||
return &fakeStreamingDiagnosticTransport{
|
||||
chunks: []jvm.DiagnosticEventChunk{
|
||||
{
|
||||
SessionID: "remote-sess",
|
||||
CommandID: "remote-cmd-1",
|
||||
Event: "diagnostic",
|
||||
Phase: "running",
|
||||
Content: "PRIVATE_KEY=-----BEG",
|
||||
},
|
||||
{
|
||||
SessionID: "remote-sess",
|
||||
CommandID: "remote-cmd-2",
|
||||
Event: "diagnostic",
|
||||
Phase: "failed",
|
||||
Content: "IN PRIVATE KEY-----\nabc123\n-----END PRIVATE KEY-----",
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
})
|
||||
defer restore()
|
||||
|
||||
res := app.JVMExecuteDiagnosticCommand(connection.ConnectionConfig{
|
||||
ID: "conn-orders",
|
||||
Type: "jvm",
|
||||
Host: "orders.internal",
|
||||
JVM: connection.JVMConfig{
|
||||
Diagnostic: connection.JVMDiagnosticConfig{
|
||||
Enabled: true,
|
||||
Transport: jvm.DiagnosticTransportAgentBridge,
|
||||
BaseURL: "http://127.0.0.1:19091/gonavi/diag",
|
||||
AllowObserveCommands: true,
|
||||
},
|
||||
},
|
||||
}, "tab-diag-1", jvm.DiagnosticCommandRequest{
|
||||
SessionID: "sess-1",
|
||||
CommandID: "cmd-event-secret",
|
||||
Command: "thread -n 5",
|
||||
Source: "manual",
|
||||
Reason: "观察线程",
|
||||
})
|
||||
|
||||
if !res.Success {
|
||||
t.Fatalf("expected accepted command, got %+v", res)
|
||||
}
|
||||
if len(emitted) != 2 {
|
||||
t.Fatalf("expected 2 emitted chunks, got %#v", emitted)
|
||||
}
|
||||
combined := ""
|
||||
for _, payload := range emitted {
|
||||
if payload.TabID != "tab-diag-1" {
|
||||
t.Fatalf("unexpected tab id in emitted payload: %#v", payload)
|
||||
}
|
||||
if payload.Chunk.SessionID != "sess-1" || payload.Chunk.CommandID != "cmd-event-secret" {
|
||||
t.Fatalf("expected emitted chunk to use request ids, got %#v", payload.Chunk)
|
||||
}
|
||||
combined += payload.Chunk.Content
|
||||
}
|
||||
for _, leaked := range []string{"PRIVATE KEY", "abc123", "-----END"} {
|
||||
if strings.Contains(combined, leaked) {
|
||||
t.Fatalf("expected emitted chunks to be redacted, leaked %q in %q", leaked, combined)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestJVMExecuteDiagnosticCommandFailsClosedWhenAuditWriteFails(t *testing.T) {
|
||||
app := NewAppWithSecretStore(nil)
|
||||
tempDir := t.TempDir()
|
||||
blockerPath := filepath.Join(tempDir, "audit-blocker")
|
||||
if err := os.WriteFile(blockerPath, []byte("blocker"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile returned error: %v", err)
|
||||
}
|
||||
app.configDir = blockerPath
|
||||
|
||||
recorder := &fakeDiagnosticTransport{}
|
||||
restore := swapJVMDiagnosticTransportFactory(func(mode string) (jvm.DiagnosticTransport, error) {
|
||||
return diagnosticTransportRecorder{recorder: recorder}, nil
|
||||
})
|
||||
defer restore()
|
||||
|
||||
readOnly := true
|
||||
res := app.JVMExecuteDiagnosticCommand(connection.ConnectionConfig{
|
||||
ID: "conn-orders",
|
||||
Type: "jvm",
|
||||
Host: "orders.internal",
|
||||
JVM: connection.JVMConfig{
|
||||
ReadOnly: &readOnly,
|
||||
Diagnostic: connection.JVMDiagnosticConfig{
|
||||
Enabled: true,
|
||||
Transport: jvm.DiagnosticTransportAgentBridge,
|
||||
BaseURL: "http://127.0.0.1:19091/gonavi/diag",
|
||||
AllowObserveCommands: true,
|
||||
},
|
||||
},
|
||||
}, "tab-diag-1", jvm.DiagnosticCommandRequest{
|
||||
SessionID: "sess-1",
|
||||
CommandID: "cmd-observe-audit",
|
||||
Command: "thread -n 5",
|
||||
Source: "manual",
|
||||
Reason: "观察线程",
|
||||
})
|
||||
|
||||
if res.Success {
|
||||
t.Fatalf("expected command to fail closed when initial audit write fails, got %+v", res)
|
||||
}
|
||||
if !strings.Contains(res.Message, "审计") {
|
||||
t.Fatalf("expected audit failure message, got %+v", res)
|
||||
}
|
||||
if recorder.executeCalls != 0 {
|
||||
t.Fatalf("expected transport ExecuteCommand not called, got %d", recorder.executeCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJVMCancelDiagnosticCommandDelegatesToTransport(t *testing.T) {
|
||||
app := NewAppWithSecretStore(nil)
|
||||
recorder := &fakeDiagnosticTransport{}
|
||||
@@ -241,6 +705,7 @@ func (d diagnosticTransportRecorder) StartSession(ctx context.Context, cfg conne
|
||||
|
||||
func (d diagnosticTransportRecorder) ExecuteCommand(ctx context.Context, cfg connection.ConnectionConfig, req jvm.DiagnosticCommandRequest) error {
|
||||
d.recorder.executeReq = req
|
||||
d.recorder.executeCalls++
|
||||
return d.recorder.ExecuteCommand(ctx, cfg, req)
|
||||
}
|
||||
|
||||
|
||||
@@ -80,11 +80,35 @@ func ValidateDiagnosticCommandPolicy(cfg connection.JVMDiagnosticConfig, command
|
||||
return category, nil
|
||||
}
|
||||
|
||||
func ValidateDiagnosticExecutionPolicy(cfg connection.ConnectionConfig, command string) (string, error) {
|
||||
diagnosticCfg, err := NormalizeDiagnosticConfig(cfg)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
category, err := ValidateDiagnosticCommandPolicy(diagnosticCfg, command)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if cfg.JVM.ReadOnly != nil && *cfg.JVM.ReadOnly {
|
||||
switch category {
|
||||
case DiagnosticCommandCategoryTrace, DiagnosticCommandCategoryMutating:
|
||||
return "", fmt.Errorf("当前连接为只读模式,仅允许观察类诊断命令")
|
||||
}
|
||||
}
|
||||
|
||||
return category, nil
|
||||
}
|
||||
|
||||
func classifyDiagnosticCommand(command string) (string, string, error) {
|
||||
normalizedCommand := strings.TrimSpace(command)
|
||||
if normalizedCommand == "" {
|
||||
return "", "", fmt.Errorf("诊断命令不能为空")
|
||||
}
|
||||
if strings.ContainsAny(normalizedCommand, "\r\n") {
|
||||
return "", "", fmt.Errorf("诊断命令不支持换行或多命令输入")
|
||||
}
|
||||
|
||||
fields := strings.Fields(strings.ToLower(normalizedCommand))
|
||||
head := fields[0]
|
||||
|
||||
@@ -29,6 +29,35 @@ func TestNormalizeDiagnosticConfigDefaultsToDisabledObserveOnly(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDiagnosticCommandPolicyRejectsMultilineCommand(t *testing.T) {
|
||||
cfg, err := NormalizeDiagnosticConfig(connection.ConnectionConfig{
|
||||
Type: "jvm",
|
||||
Host: "orders.internal",
|
||||
JVM: connection.JVMConfig{
|
||||
Diagnostic: connection.JVMDiagnosticConfig{
|
||||
Enabled: true,
|
||||
Transport: DiagnosticTransportAgentBridge,
|
||||
BaseURL: "http://127.0.0.1:19091/gonavi/diag",
|
||||
AllowObserveCommands: true,
|
||||
AllowTraceCommands: true,
|
||||
AllowMutatingCommands: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NormalizeDiagnosticConfig returned error: %v", err)
|
||||
}
|
||||
|
||||
for _, command := range []string{
|
||||
"thread -n 1\nognl '@java.lang.System@setProperty(\"x\",\"y\")'",
|
||||
"thread -n 1\rwatch com.foo.OrderService submitOrder '{params}'",
|
||||
} {
|
||||
if _, err := ValidateDiagnosticCommandPolicy(cfg, command); err == nil {
|
||||
t.Fatalf("expected multiline command to be rejected: %q", command)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyDiagnosticCommandRejectsMutatingCommandWhenDisabled(t *testing.T) {
|
||||
cfg, err := NormalizeDiagnosticConfig(connection.ConnectionConfig{
|
||||
Type: "jvm",
|
||||
|
||||
215
internal/jvm/diagnostic_redaction.go
Normal file
215
internal/jvm/diagnostic_redaction.go
Normal file
@@ -0,0 +1,215 @@
|
||||
package jvm
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
const diagnosticRedactionMask = "********"
|
||||
|
||||
const diagnosticSensitiveKeyPattern = `(?: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 diagnosticSensitiveKeyBody = `[A-Za-z0-9_.\- \t]*` + diagnosticSensitiveKeyPattern + `[A-Za-z0-9_.\- \t]*`
|
||||
|
||||
var (
|
||||
diagnosticPEMEndPattern = regexp.MustCompile(`(?i)-----END [^-]*(?:PRIVATE KEY|SECRET|TOKEN|CREDENTIAL)[^-]*-----`)
|
||||
diagnosticPEMBeginPrefixPattern = regexp.MustCompile(`(?is)-----BEGIN[\s\S]*$`)
|
||||
diagnosticPEMEndContinuationPattern = regexp.MustCompile(`(?is)^[\s\S]*?-----END [^-]*(?:PRIVATE KEY|SECRET|TOKEN|CREDENTIAL)[^-]*-----`)
|
||||
diagnosticCompletePEMPattern = regexp.MustCompile(`(?is)-----BEGIN [^-]*(?:PRIVATE KEY|SECRET|TOKEN|CREDENTIAL)[\s\S]*?-----END [^-]*(?:PRIVATE KEY|SECRET|TOKEN|CREDENTIAL)[^-]*-----`)
|
||||
diagnosticPartialPEMPattern = regexp.MustCompile(`(?is)-----BEGIN [^-]*(?:PRIVATE KEY|SECRET|TOKEN|CREDENTIAL)[\s\S]*$`)
|
||||
diagnosticSensitivePEMLabels = []string{
|
||||
"PRIVATE KEY",
|
||||
"RSA PRIVATE KEY",
|
||||
"DSA PRIVATE KEY",
|
||||
"EC PRIVATE KEY",
|
||||
"OPENSSH PRIVATE KEY",
|
||||
"ENCRYPTED PRIVATE KEY",
|
||||
"SECRET",
|
||||
"TOKEN",
|
||||
"CREDENTIAL",
|
||||
}
|
||||
diagnosticDoubleQuotedValuePattern = regexp.MustCompile(`(?i)(")(` + diagnosticSensitiveKeyBody + `)(")([ \t]*:[ \t]*)(")((?:\\.|[^"\\])*)(")`)
|
||||
diagnosticSingleQuotedValuePattern = regexp.MustCompile(`(?i)(')(` + diagnosticSensitiveKeyBody + `)(')([ \t]*:[ \t]*)(')((?:\\.|[^'\\])*)(')`)
|
||||
diagnosticDoubleQuotedScalarPattern = regexp.MustCompile(`(?i)(")(` + diagnosticSensitiveKeyBody + `)(")([ \t]*:[ \t]*)(true|false|null|-?\d+(?:\.\d+)?)`)
|
||||
diagnosticSingleQuotedScalarPattern = regexp.MustCompile(`(?i)(')(` + diagnosticSensitiveKeyBody + `)(')([ \t]*:[ \t]*)(true|false|null|-?\d+(?:\.\d+)?)`)
|
||||
diagnosticUnquotedKeyValuePattern = regexp.MustCompile(`(?i)(^|[\r\n,;{\[?&]|\s)(` + diagnosticSensitiveKeyBody + `)([ \t]*[:=][ \t]*)([^\r\n&]*)`)
|
||||
diagnosticSensitivePEMBeginWithKeyPattern = regexp.MustCompile(`(?is)` + diagnosticSensitiveKeyBody + `[ \t]*[:=][ \t]*-----BEGIN[\s\S]*$`)
|
||||
diagnosticSensitiveKeyAssignmentTailPattern = regexp.MustCompile(`(?is)(^|[\r\n,;{\[?&]|\s)` + diagnosticSensitiveKeyBody + `[ \t]*[:=][ \t]*([^\r\n&]*)$`)
|
||||
)
|
||||
|
||||
type DiagnosticRedactionState struct {
|
||||
InsideSensitivePEM bool
|
||||
SawSensitivePEM bool
|
||||
PendingPEMBeginFragment string
|
||||
}
|
||||
|
||||
type DiagnosticOutputRedactor struct {
|
||||
mu sync.Mutex
|
||||
states map[string]*DiagnosticRedactionState
|
||||
}
|
||||
|
||||
func NewDiagnosticOutputRedactor() *DiagnosticOutputRedactor {
|
||||
return &DiagnosticOutputRedactor{states: map[string]*DiagnosticRedactionState{}}
|
||||
}
|
||||
|
||||
func (r *DiagnosticOutputRedactor) RedactChunk(chunk DiagnosticEventChunk) DiagnosticEventChunk {
|
||||
chunk.Content = r.RedactContent(chunk.SessionID, chunk.CommandID, chunk.Content)
|
||||
return chunk
|
||||
}
|
||||
|
||||
func (r *DiagnosticOutputRedactor) RedactContent(sessionID string, commandID string, content string) string {
|
||||
if r == nil {
|
||||
return RedactDiagnosticOutput(content)
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
key := diagnosticRedactionStateKey(sessionID, commandID)
|
||||
state := r.states[key]
|
||||
if state == nil {
|
||||
state = &DiagnosticRedactionState{}
|
||||
r.states[key] = state
|
||||
}
|
||||
return redactDiagnosticOutputWithState(content, state)
|
||||
}
|
||||
|
||||
func RedactDiagnosticOutput(content string) string {
|
||||
state := DiagnosticRedactionState{}
|
||||
return redactDiagnosticOutputWithState(content, &state)
|
||||
}
|
||||
|
||||
func diagnosticRedactionStateKey(sessionID string, commandID string) string {
|
||||
return strings.TrimSpace(sessionID) + "::" + strings.TrimSpace(commandID)
|
||||
}
|
||||
|
||||
func redactDiagnosticOutputWithState(content string, state *DiagnosticRedactionState) string {
|
||||
text := content
|
||||
if state.PendingPEMBeginFragment != "" {
|
||||
pending := state.PendingPEMBeginFragment
|
||||
state.PendingPEMBeginFragment = ""
|
||||
if isSensitivePEMBeginFragment(pending + content) {
|
||||
state.InsideSensitivePEM = true
|
||||
state.SawSensitivePEM = true
|
||||
}
|
||||
}
|
||||
if state.InsideSensitivePEM {
|
||||
pemEnd := diagnosticPEMEndPattern.FindStringIndex(text)
|
||||
if pemEnd == nil {
|
||||
return diagnosticRedactionMask
|
||||
}
|
||||
state.InsideSensitivePEM = false
|
||||
state.SawSensitivePEM = true
|
||||
text = diagnosticRedactionMask + diagnosticPEMEndPattern.ReplaceAllString(text[pemEnd[0]:], "")
|
||||
} else if state.SawSensitivePEM && diagnosticPEMEndPattern.MatchString(text) {
|
||||
text = diagnosticPEMEndContinuationPattern.ReplaceAllString(text, diagnosticRedactionMask)
|
||||
}
|
||||
|
||||
text = diagnosticCompletePEMPattern.ReplaceAllStringFunc(text, func(string) string {
|
||||
state.SawSensitivePEM = true
|
||||
return diagnosticRedactionMask
|
||||
})
|
||||
text = diagnosticPartialPEMPattern.ReplaceAllStringFunc(text, func(match string) string {
|
||||
state.SawSensitivePEM = true
|
||||
state.InsideSensitivePEM = !diagnosticPEMEndPattern.MatchString(match)
|
||||
return diagnosticRedactionMask
|
||||
})
|
||||
|
||||
if !state.InsideSensitivePEM && !diagnosticPEMEndPattern.MatchString(content) && hasSensitivePEMPartialBeginWithKey(content) {
|
||||
state.InsideSensitivePEM = true
|
||||
state.SawSensitivePEM = true
|
||||
}
|
||||
if !state.InsideSensitivePEM && hasSensitivePEMBeginPrefix(text) {
|
||||
state.InsideSensitivePEM = true
|
||||
state.SawSensitivePEM = true
|
||||
text = diagnosticPEMBeginPrefixPattern.ReplaceAllString(text, diagnosticRedactionMask)
|
||||
}
|
||||
if !state.InsideSensitivePEM && !diagnosticPEMEndPattern.MatchString(content) {
|
||||
if fragment := sensitivePEMBeginTailFragment(content); fragment != "" {
|
||||
state.PendingPEMBeginFragment = fragment
|
||||
state.SawSensitivePEM = true
|
||||
text = redactTrailingPEMBeginFragment(text, fragment)
|
||||
}
|
||||
}
|
||||
|
||||
return redactDiagnosticKeyValues(text)
|
||||
}
|
||||
|
||||
func hasSensitivePEMBeginPrefix(value string) bool {
|
||||
prefix := diagnosticPEMBeginPrefixPattern.FindString(value)
|
||||
if prefix == "" {
|
||||
return false
|
||||
}
|
||||
if isSensitivePEMBeginFragment(prefix) {
|
||||
return true
|
||||
}
|
||||
return diagnosticSensitivePEMBeginWithKeyPattern.MatchString(value)
|
||||
}
|
||||
|
||||
func hasSensitivePEMPartialBeginWithKey(value string) bool {
|
||||
matches := diagnosticSensitiveKeyAssignmentTailPattern.FindAllStringSubmatch(value, -1)
|
||||
for _, match := range matches {
|
||||
if len(match) >= 3 && isSensitivePEMBeginFragment(match[2]) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isSensitivePEMBeginFragment(value string) bool {
|
||||
fragment := strings.ToUpper(strings.TrimSpace(value))
|
||||
if fragment == "" {
|
||||
return false
|
||||
}
|
||||
marker := "-----BEGIN"
|
||||
if len(fragment) <= len(marker) {
|
||||
return strings.HasPrefix(marker, fragment) && strings.HasPrefix(fragment, "-")
|
||||
}
|
||||
if !strings.HasPrefix(fragment, marker) {
|
||||
return false
|
||||
}
|
||||
label := strings.TrimSpace(strings.TrimRight(strings.TrimPrefix(fragment, marker), "-"))
|
||||
label = strings.Join(strings.Fields(label), " ")
|
||||
if label == "" {
|
||||
return true
|
||||
}
|
||||
for _, item := range diagnosticSensitivePEMLabels {
|
||||
if strings.HasPrefix(item, label) || strings.HasPrefix(label, item) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func sensitivePEMBeginTailFragment(value string) string {
|
||||
line := value
|
||||
if idx := strings.LastIndexAny(line, "\r\n"); idx >= 0 {
|
||||
line = line[idx+1:]
|
||||
}
|
||||
for start := 0; start < len(line); start++ {
|
||||
fragment := line[start:]
|
||||
if isSensitivePEMBeginFragment(fragment) {
|
||||
return fragment
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func redactTrailingPEMBeginFragment(value string, fragment string) string {
|
||||
if fragment == "" {
|
||||
return value
|
||||
}
|
||||
idx := strings.LastIndex(value, fragment)
|
||||
if idx < 0 {
|
||||
return value
|
||||
}
|
||||
return value[:idx] + diagnosticRedactionMask
|
||||
}
|
||||
|
||||
func redactDiagnosticKeyValues(value string) string {
|
||||
text := diagnosticDoubleQuotedValuePattern.ReplaceAllString(value, `${1}${2}${3}${4}${5}`+diagnosticRedactionMask+`${7}`)
|
||||
text = diagnosticSingleQuotedValuePattern.ReplaceAllString(text, `${1}${2}${3}${4}${5}`+diagnosticRedactionMask+`${7}`)
|
||||
text = diagnosticDoubleQuotedScalarPattern.ReplaceAllString(text, `${1}${2}${3}${4}`+diagnosticRedactionMask)
|
||||
text = diagnosticSingleQuotedScalarPattern.ReplaceAllString(text, `${1}${2}${3}${4}`+diagnosticRedactionMask)
|
||||
text = diagnosticUnquotedKeyValuePattern.ReplaceAllString(text, `${1}${2}${3}`+diagnosticRedactionMask)
|
||||
return text
|
||||
}
|
||||
106
internal/jvm/diagnostic_redaction_test.go
Normal file
106
internal/jvm/diagnostic_redaction_test.go
Normal file
@@ -0,0 +1,106 @@
|
||||
package jvm
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDiagnosticOutputRedactorRedactsSensitiveKeyValues(t *testing.T) {
|
||||
redactor := NewDiagnosticOutputRedactor()
|
||||
|
||||
chunk := redactor.RedactChunk(DiagnosticEventChunk{
|
||||
SessionID: "sess-1",
|
||||
CommandID: "cmd-1",
|
||||
Content: strings.Join([]string{
|
||||
"password=secret-token",
|
||||
"api_key: api-secret",
|
||||
"Authorization: Bearer header-secret",
|
||||
`{"refresh_token":"json-secret"}`,
|
||||
"https://svc.local/callback?access_token=query-secret&x=1",
|
||||
}, "\n"),
|
||||
})
|
||||
|
||||
for _, leaked := range []string{"secret-token", "api-secret", "header-secret", "json-secret", "query-secret"} {
|
||||
if strings.Contains(chunk.Content, leaked) {
|
||||
t.Fatalf("redacted chunk leaked %q: %q", leaked, chunk.Content)
|
||||
}
|
||||
}
|
||||
for _, masked := range []string{"password=********", "api_key: ********", "Authorization: ********", `"refresh_token":"********"`, "access_token=********"} {
|
||||
if !strings.Contains(chunk.Content, masked) {
|
||||
t.Fatalf("expected redacted chunk to contain %q, got %q", masked, chunk.Content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiagnosticOutputRedactorRedactsPEMAcrossChunksAndRepeatedContinuation(t *testing.T) {
|
||||
redactor := NewDiagnosticOutputRedactor()
|
||||
|
||||
first := redactor.RedactChunk(DiagnosticEventChunk{
|
||||
SessionID: "sess-1",
|
||||
CommandID: "cmd-1",
|
||||
Content: "PRIVATE_KEY=-----BEGIN RSA PRIVATE K",
|
||||
})
|
||||
second := redactor.RedactChunk(DiagnosticEventChunk{
|
||||
SessionID: "sess-1",
|
||||
CommandID: "cmd-1",
|
||||
Content: "EY-----\nabc123\n-----END RSA PRIVATE KEY-----",
|
||||
})
|
||||
third := redactor.RedactContent("sess-1", "cmd-1", "abc123\n-----END RSA PRIVATE KEY-----")
|
||||
|
||||
combined := strings.Join([]string{first.Content, second.Content, third}, "\n")
|
||||
for _, leaked := range []string{"RSA PRIVATE K", "EY-----", "abc123"} {
|
||||
if strings.Contains(combined, leaked) {
|
||||
t.Fatalf("redacted PEM stream leaked %q: %q", leaked, combined)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiagnosticOutputRedactorRedactsPEMWhenBeginMarkerIsSplit(t *testing.T) {
|
||||
stream := "PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\nabc123\n-----END PRIVATE KEY-----"
|
||||
beginIndex := strings.Index(stream, "-----BEGIN")
|
||||
if beginIndex < 0 {
|
||||
t.Fatal("test stream missing PEM begin marker")
|
||||
}
|
||||
|
||||
for split := beginIndex + 1; split < beginIndex+len("-----BEGIN PRIVATE KEY"); split++ {
|
||||
redactor := NewDiagnosticOutputRedactor()
|
||||
combined := redactor.RedactContent("sess-1", "cmd-1", stream[:split]) + redactor.RedactContent("sess-1", "cmd-1", stream[split:])
|
||||
for _, leaked := range []string{"PRIVATE KEY", "abc123", "-----END"} {
|
||||
if strings.Contains(combined, leaked) {
|
||||
t.Fatalf("split at %d leaked %q: %q", split, leaked, combined)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiagnosticOutputRedactorRedactsRawPEMWhenBeginMarkerIsSplit(t *testing.T) {
|
||||
stream := "-----BEGIN PRIVATE KEY-----\nabc123\n-----END PRIVATE KEY-----"
|
||||
for split := 1; split < len("-----BEGIN PRIVATE KEY"); split++ {
|
||||
redactor := NewDiagnosticOutputRedactor()
|
||||
combined := redactor.RedactContent("sess-1", "cmd-1", stream[:split]) + redactor.RedactContent("sess-1", "cmd-1", stream[split:])
|
||||
for _, leaked := range []string{"-----BEG", "PRIVATE KEY", "abc123", "-----END"} {
|
||||
if strings.Contains(combined, leaked) {
|
||||
t.Fatalf("split at %d leaked %q: %q", split, leaked, combined)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiagnosticOutputRedactorDoesNotMaskUnrelatedCommandOutput(t *testing.T) {
|
||||
redactor := NewDiagnosticOutputRedactor()
|
||||
|
||||
_ = redactor.RedactChunk(DiagnosticEventChunk{
|
||||
SessionID: "sess-1",
|
||||
CommandID: "cmd-1",
|
||||
Content: "PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\nabc123",
|
||||
})
|
||||
other := redactor.RedactChunk(DiagnosticEventChunk{
|
||||
SessionID: "sess-1",
|
||||
CommandID: "cmd-2",
|
||||
Content: "thread_name=main",
|
||||
})
|
||||
|
||||
if other.Content != "thread_name=main" {
|
||||
t.Fatalf("expected unrelated command output unchanged, got %q", other.Content)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user