🐛 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

@@ -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,
})

View File

@@ -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)
}

View File

@@ -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]

View File

@@ -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",

View 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
}

View 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)
}
}