From 1b31c54917b2b2b002ef1ebdfd4c6ba393294a58 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Mon, 27 Apr 2026 11:31:20 +0800 Subject: [PATCH 01/14] =?UTF-8?q?=F0=9F=90=9B=20fix(redis):=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E7=B2=BE=E7=A1=AE=E6=90=9C=E7=B4=A2=E6=97=A0=E6=B3=95?= =?UTF-8?q?=E5=91=BD=E4=B8=AD=E5=91=BD=E5=90=8D=E7=A9=BA=E9=97=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 精确搜索识别无通配符的 Redis literal pattern - 同时查询完整 Key 与同名命名空间前缀 - 修复输入 Agent 无法显示 Agent 文件夹的问题 - 避免误匹配 AgentCapacity、AgentState 等相似前缀 - 补充 glob literal 与命名空间搜索回归测试 - 更新 Redis 精确搜索输入提示文案 --- frontend/src/components/RedisViewer.tsx | 2 +- internal/redis/redis_impl.go | 57 ++++++++++++++++++++++ internal/redis/redis_impl_test.go | 63 +++++++++++++++++++++++++ 3 files changed, 121 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/RedisViewer.tsx b/frontend/src/components/RedisViewer.tsx index 2edeaf4..c9cfe7e 100644 --- a/frontend/src/components/RedisViewer.tsx +++ b/frontend/src/components/RedisViewer.tsx @@ -1852,7 +1852,7 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { = len(pattern) { + return "", false + } + i++ + builder.WriteByte(pattern[i]) + continue + } + if char == '*' || char == '?' || char == '[' { + return "", false + } + builder.WriteByte(char) + } + return builder.String(), true +} + +func escapeRedisGlobLiteral(value string) string { + var builder strings.Builder + for i := 0; i < len(value); i++ { + char := value[i] + if char == '*' || char == '?' || char == '[' || char == ']' || char == '\\' { + builder.WriteByte('\\') + } + builder.WriteByte(char) + } + return builder.String() +} + +func redisExactSearchPattern(literalKey string) (string, string) { + return literalKey, escapeRedisGlobLiteral(literalKey) + ":*" +} + func (r *RedisClientImpl) toPhysicalKeys(keys []string) []string { if len(keys) == 0 { return nil @@ -367,6 +407,15 @@ func (r *RedisClientImpl) ScanKeys(pattern string, cursor uint64, count int64) ( if pattern == "" { pattern = "*" } + exactPhysicalKey := "" + if literalKey, ok := redisGlobPatternLiteralKey(pattern); ok { + exactKey, namespacePattern := redisExactSearchPattern(literalKey) + exactPhysicalKey = r.toPhysicalKey(exactKey) + if exactPhysicalKey == "" { + return &RedisScanResult{Keys: []RedisKeyInfo{}, Cursor: "0"}, nil + } + pattern = namespacePattern + } physicalPattern := r.toPhysicalPattern(pattern) isSearchPattern := pattern != "*" @@ -393,6 +442,10 @@ func (r *RedisClientImpl) ScanKeys(pattern string, cursor uint64, count int64) ( keys := make([]string, 0, int(targetCount)) seen := make(map[string]struct{}, int(targetCount)) var mu sync.Mutex + if exactPhysicalKey != "" { + keys = append(keys, exactPhysicalKey) + seen[exactPhysicalKey] = struct{}{} + } err := r.clusterClient.ForEachMaster(ctx, func(nodeCtx context.Context, node *redis.Client) error { var nodeCursor uint64 @@ -453,6 +506,10 @@ func (r *RedisClientImpl) ScanKeys(pattern string, cursor uint64, count int64) ( keys := make([]string, 0, int(targetCount)) seen := make(map[string]struct{}, int(targetCount)) + if exactPhysicalKey != "" && currentCursor == 0 { + keys = append(keys, exactPhysicalKey) + seen[exactPhysicalKey] = struct{}{} + } for len(keys) < int(targetCount) { if time.Since(scanStartedAt) >= maxDuration { diff --git a/internal/redis/redis_impl_test.go b/internal/redis/redis_impl_test.go index 914e088..9f08b79 100644 --- a/internal/redis/redis_impl_test.go +++ b/internal/redis/redis_impl_test.go @@ -120,6 +120,69 @@ func TestNormalizeRedisGetValueError(t *testing.T) { } } +func TestRedisGlobPatternLiteralKey(t *testing.T) { + tests := []struct { + name string + pattern string + wantKey string + wantExact bool + }{ + {name: "plain exact key", pattern: "Agent", wantKey: "Agent", wantExact: true}, + {name: "escaped glob characters stay literal", pattern: `user:\*:\[id\]\?\\raw`, wantKey: `user:*:[id]?\raw`, wantExact: true}, + {name: "fuzzy wildcard is not exact", pattern: "*[aA][gG][eE][nN][tT]*", wantExact: false}, + {name: "unescaped suffix wildcard is not exact", pattern: "Agent*", wantExact: false}, + {name: "unescaped single character wildcard is not exact", pattern: "Agent?", wantExact: false}, + {name: "unescaped character class is not exact", pattern: "Agent[0-9]", wantExact: false}, + {name: "empty pattern is not exact", pattern: "", wantExact: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotKey, gotExact := redisGlobPatternLiteralKey(tt.pattern) + if gotExact != tt.wantExact { + t.Fatalf("redisGlobPatternLiteralKey(%q) exact=%v, want %v", tt.pattern, gotExact, tt.wantExact) + } + if gotKey != tt.wantKey { + t.Fatalf("redisGlobPatternLiteralKey(%q) key=%q, want %q", tt.pattern, gotKey, tt.wantKey) + } + }) + } +} + +func TestRedisExactSearchPattern(t *testing.T) { + tests := []struct { + name string + literalKey string + wantExactKey string + wantNamespace string + }{ + { + name: "plain namespace folder", + literalKey: "Agent", + wantExactKey: "Agent", + wantNamespace: "Agent:*", + }, + { + name: "escaped namespace keeps glob chars literal", + literalKey: `user:*:[id]?\raw`, + wantExactKey: `user:*:[id]?\raw`, + wantNamespace: `user:\*:\[id\]\?\\raw:*`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotExactKey, gotNamespace := redisExactSearchPattern(tt.literalKey) + if gotExactKey != tt.wantExactKey { + t.Fatalf("redisExactSearchPattern(%q) exactKey=%q, want %q", tt.literalKey, gotExactKey, tt.wantExactKey) + } + if gotNamespace != tt.wantNamespace { + t.Fatalf("redisExactSearchPattern(%q) namespace=%q, want %q", tt.literalKey, gotNamespace, tt.wantNamespace) + } + }) + } +} + func TestReadRedisHashEntriesWithFallbackUsesHScanWhenHGetAllForbidden(t *testing.T) { scanCalls := 0 values, length, err := readRedisHashEntriesWithFallback( From ffc4f2c2d979cf27bb358d391065efaf228ac3c1 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Tue, 28 Apr 2026 09:42:21 +0800 Subject: [PATCH 02/14] =?UTF-8?q?=F0=9F=90=9B=20fix(jvm):=20=E5=BC=BA?= =?UTF-8?q?=E5=8C=96=E5=8F=98=E6=9B=B4=E7=A1=AE=E8=AE=A4=E4=BB=A4=E7=89=8C?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将 JVM 变更确认从可重算校验值升级为服务端发放的一次性令牌,避免未预览、重放或上下文变更后继续执行高风险变更。 --- frontend/wailsjs/go/models.ts | 2 + internal/app/app.go | 37 +- internal/app/methods_jvm.go | 221 +++++- internal/app/methods_jvm_test.go | 1141 +++++++++++++++++++++++++++++- internal/jvm/guard.go | 91 ++- internal/jvm/guard_test.go | 289 ++++++++ internal/jvm/types.go | 16 +- 7 files changed, 1742 insertions(+), 55 deletions(-) diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index bebcbf9..fff3fea 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -948,6 +948,7 @@ export namespace jvm { reason: string; source?: string; expectedVersion?: string; + confirmationToken?: string; payload?: Record; static createFrom(source: any = {}) { @@ -962,6 +963,7 @@ export namespace jvm { this.reason = source["reason"]; this.source = source["source"]; this.expectedVersion = source["expectedVersion"]; + this.confirmationToken = source["confirmationToken"]; this.payload = source["payload"]; } } diff --git a/internal/app/app.go b/internal/app/app.go index a5cf390..7404d05 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -55,17 +55,20 @@ type queryContext struct { // App struct type App struct { - ctx context.Context - startedAt time.Time - dbCache map[string]cachedDatabase // Cache for DB connections - connectFailures map[string]cachedConnectFailure - mu sync.RWMutex // Mutex for cache access - updateMu sync.Mutex - updateState updateState - queryMu sync.RWMutex - configDir string - secretStore secretstore.SecretStore - runningQueries map[string]queryContext // queryID -> cancelFunc and start time + ctx context.Context + startedAt time.Time + dbCache map[string]cachedDatabase // Cache for DB connections + connectFailures map[string]cachedConnectFailure + mu sync.RWMutex // Mutex for cache access + updateMu sync.Mutex + updateState updateState + queryMu sync.RWMutex + configDir string + secretStore secretstore.SecretStore + runningQueries map[string]queryContext // queryID -> cancelFunc and start time + jvmPreviewTokenMu sync.Mutex + jvmPreviewTokens map[string]jvmPreviewConfirmationToken + jvmPreviewTokenTTL time.Duration } // NewApp creates a new App application struct @@ -78,11 +81,13 @@ func NewAppWithSecretStore(store secretstore.SecretStore) *App { store = secretstore.NewUnavailableStore("secret store unavailable") } return &App{ - dbCache: make(map[string]cachedDatabase), - connectFailures: make(map[string]cachedConnectFailure), - runningQueries: make(map[string]queryContext), - configDir: resolveAppConfigDir(), - secretStore: store, + dbCache: make(map[string]cachedDatabase), + connectFailures: make(map[string]cachedConnectFailure), + runningQueries: make(map[string]queryContext), + configDir: resolveAppConfigDir(), + secretStore: store, + jvmPreviewTokens: make(map[string]jvmPreviewConfirmationToken), + jvmPreviewTokenTTL: defaultJVMPreviewConfirmationTokenTTL, } } diff --git a/internal/app/methods_jvm.go b/internal/app/methods_jvm.go index cef2196..7e0c94f 100644 --- a/internal/app/methods_jvm.go +++ b/internal/app/methods_jvm.go @@ -1,15 +1,44 @@ package app import ( + "crypto/sha256" + "crypto/subtle" + "encoding/hex" + "encoding/json" + "fmt" "path/filepath" "strings" + "time" "GoNavi-Wails/internal/connection" "GoNavi-Wails/internal/jvm" + "github.com/google/uuid" ) var newJVMProvider = jvm.NewProvider +const defaultJVMPreviewConfirmationTokenTTL = 10 * time.Minute + +type jvmPreviewConfirmationToken struct { + contextHash string + expiresAt time.Time +} + +type jvmPreviewConfirmationContext struct { + ConfigHash string `json:"configHash"` + ProviderMode string `json:"providerMode"` + ResourceID string `json:"resourceId"` + Action string `json:"action"` + Reason string `json:"reason"` + Source string `json:"source"` + ExpectedVersion string `json:"expectedVersion"` + PayloadHash string `json:"payloadHash"` + PreviewChecksum string `json:"previewChecksum"` + RiskLevel string `json:"riskLevel"` + BeforeVersion string `json:"beforeVersion"` + AfterVersion string `json:"afterVersion"` +} + func buildJVMCapabilityError(mode string, cfg connection.ConnectionConfig, err error) jvm.Capability { probeCfg := cfg probeCfg.JVM.PreferredMode = mode @@ -40,6 +69,119 @@ func resolveJVMProviderForMode(cfg connection.ConnectionConfig, mode string) (co return normalized, provider, nil } +func (a *App) issueJVMPreviewConfirmationToken(cfg connection.ConnectionConfig, req jvm.ChangeRequest, preview jvm.ChangePreview) (string, error) { + contextHash, err := buildJVMPreviewConfirmationContextHash(cfg, req, preview) + if err != nil { + return "", err + } + + token := uuid.NewString() + now := time.Now() + ttl := a.jvmPreviewTokenTTL + if ttl <= 0 { + ttl = defaultJVMPreviewConfirmationTokenTTL + } + + a.jvmPreviewTokenMu.Lock() + defer a.jvmPreviewTokenMu.Unlock() + if a.jvmPreviewTokens == nil { + a.jvmPreviewTokens = make(map[string]jvmPreviewConfirmationToken) + } + a.pruneExpiredJVMPreviewConfirmationTokensLocked(now) + a.jvmPreviewTokens[token] = jvmPreviewConfirmationToken{ + contextHash: contextHash, + expiresAt: now.Add(ttl), + } + return token, nil +} + +func (a *App) consumeJVMPreviewConfirmationToken(cfg connection.ConnectionConfig, req jvm.ChangeRequest, preview jvm.ChangePreview) error { + if !preview.RequiresConfirmation { + return nil + } + + if strings.TrimSpace(preview.ConfirmationToken) == "" { + return fmt.Errorf("预览确认令牌缺失,请重新预览后再提交") + } + + token := strings.TrimSpace(req.ConfirmationToken) + if token == "" { + return fmt.Errorf("缺少确认令牌,请先完成预览确认") + } + + expectedHash, err := buildJVMPreviewConfirmationContextHash(cfg, req, preview) + if err != nil { + return err + } + + now := time.Now() + a.jvmPreviewTokenMu.Lock() + if a.jvmPreviewTokens == nil { + a.jvmPreviewTokens = make(map[string]jvmPreviewConfirmationToken) + } + a.pruneExpiredJVMPreviewConfirmationTokensLocked(now) + entry, ok := a.jvmPreviewTokens[token] + if ok { + delete(a.jvmPreviewTokens, token) + } + a.jvmPreviewTokenMu.Unlock() + + if !ok { + return fmt.Errorf("确认令牌已失效,请重新预览并确认") + } + if !entry.expiresAt.After(now) { + return fmt.Errorf("确认令牌已过期,请重新预览并确认") + } + if subtle.ConstantTimeCompare([]byte(entry.contextHash), []byte(expectedHash)) != 1 { + return fmt.Errorf("确认令牌不匹配,请重新预览并确认") + } + return nil +} + +func (a *App) pruneExpiredJVMPreviewConfirmationTokensLocked(now time.Time) { + for token, entry := range a.jvmPreviewTokens { + if !entry.expiresAt.After(now) { + delete(a.jvmPreviewTokens, token) + } + } +} + +func buildJVMPreviewConfirmationContextHash(cfg connection.ConnectionConfig, req jvm.ChangeRequest, preview jvm.ChangePreview) (string, error) { + configHash, err := hashJSONValue(cfg) + if err != nil { + return "", fmt.Errorf("生成 JVM 预览上下文失败: %w", err) + } + payloadHash, err := hashJSONValue(req.Payload) + if err != nil { + return "", fmt.Errorf("生成 JVM 预览载荷摘要失败: %w", err) + } + + input := jvmPreviewConfirmationContext{ + ConfigHash: configHash, + ProviderMode: strings.TrimSpace(cfg.JVM.PreferredMode), + ResourceID: strings.TrimSpace(req.ResourceID), + Action: strings.TrimSpace(req.Action), + Reason: strings.TrimSpace(req.Reason), + Source: strings.TrimSpace(req.Source), + ExpectedVersion: strings.TrimSpace(req.ExpectedVersion), + PayloadHash: payloadHash, + PreviewChecksum: strings.TrimSpace(preview.ConfirmationToken), + RiskLevel: strings.TrimSpace(preview.RiskLevel), + BeforeVersion: strings.TrimSpace(preview.Before.Version), + AfterVersion: strings.TrimSpace(preview.After.Version), + } + return hashJSONValue(input) +} + +func hashJSONValue(value any) (string, error) { + encoded, err := json.Marshal(value) + if err != nil { + return "", err + } + sum := sha256.Sum256(encoded) + return hex.EncodeToString(sum[:]), nil +} + func (a *App) TestJVMConnection(cfg connection.ConnectionConfig) connection.QueryResult { normalized, provider, err := resolveJVMProvider(cfg) if err != nil { @@ -97,6 +239,13 @@ func (a *App) JVMPreviewChange(cfg connection.ConnectionConfig, req jvm.ChangeRe if err != nil { return connection.QueryResult{Success: false, Message: err.Error()} } + if preview.Allowed && preview.RequiresConfirmation { + token, err := a.issueJVMPreviewConfirmationToken(normalized, req, preview) + if err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + preview.ConfirmationToken = token + } return connection.QueryResult{Success: true, Data: preview} } @@ -124,26 +273,66 @@ func (a *App) JVMApplyChange(cfg connection.ConnectionConfig, req jvm.ChangeRequ } return connection.QueryResult{Success: false, Message: message} } - - result, err := provider.ApplyChange(a.ctx, normalized, req) - if err != nil { + if err := a.consumeJVMPreviewConfirmationToken(normalized, req, preview); err != nil { return connection.QueryResult{Success: false, Message: err.Error()} } - if err := jvm.NewAuditStore(filepath.Join(a.auditRootDir(), "jvm_audit.jsonl")).Append(jvm.AuditRecord{ - ConnectionID: normalized.ID, - ProviderMode: normalized.JVM.PreferredMode, - ResourceID: req.ResourceID, - Action: req.Action, - Reason: req.Reason, - Source: req.Source, - Result: result.Status, - }); err != nil { - if strings.TrimSpace(result.Message) == "" { - result.Message = "变更已执行,但审计记录写入失败: " + err.Error() - } else { - result.Message += ";审计记录写入失败: " + err.Error() + auditStore := jvm.NewAuditStore(filepath.Join(a.auditRootDir(), "jvm_audit.jsonl")) + appendAuditRecord := func(record jvm.AuditRecord) error { + return auditStore.Append(record) + } + appendAudit := func(result string, timestamp int64) error { + return appendAuditRecord(jvm.AuditRecord{ + Timestamp: timestamp, + ConnectionID: normalized.ID, + ProviderMode: normalized.JVM.PreferredMode, + ResourceID: req.ResourceID, + Action: req.Action, + Reason: req.Reason, + Source: req.Source, + Result: result, + }) + } + appendWarning := func(message string, warning string) string { + message = strings.TrimSpace(message) + warning = strings.TrimSpace(warning) + if warning == "" { + return message } + if message == "" { + return warning + } + return message + ";" + warning + } + + pendingTimestamp := time.Now().UnixMilli() + terminalAuditTimestamp := func() int64 { + ts := time.Now().UnixMilli() + if ts <= pendingTimestamp { + return pendingTimestamp + 1 + } + return ts + } + + if err := appendAudit("pending", pendingTimestamp); err != nil { + return connection.QueryResult{Success: false, Message: "审计记录写入失败,已阻止 JVM 变更: " + err.Error()} + } + + result, err := provider.ApplyChange(a.ctx, normalized, req) + if err != nil { + if auditErr := appendAudit("failed", terminalAuditTimestamp()); auditErr != nil { + return connection.QueryResult{Success: false, Message: err.Error() + ";失败审计写入失败: " + auditErr.Error()} + } + return connection.QueryResult{Success: false, Message: err.Error()} + } + + terminalResult := strings.TrimSpace(result.Status) + if terminalResult == "" { + terminalResult = "applied" + } + if err := appendAudit(terminalResult, terminalAuditTimestamp()); err != nil { + result.Message = appendWarning(result.Message, "终态审计写入失败: "+err.Error()) + return connection.QueryResult{Success: true, Message: result.Message, Data: result} } return connection.QueryResult{Success: true, Data: result} diff --git a/internal/app/methods_jvm_test.go b/internal/app/methods_jvm_test.go index 5bd21a6..b1c58d5 100644 --- a/internal/app/methods_jvm_test.go +++ b/internal/app/methods_jvm_test.go @@ -3,10 +3,12 @@ package app import ( "context" "errors" + "fmt" "os" "path/filepath" "strings" "testing" + "time" "GoNavi-Wails/internal/connection" "GoNavi-Wails/internal/jvm" @@ -25,6 +27,7 @@ type fakeJVMProvider struct { previewErr error apply jvm.ApplyResult applyErr error + applyFn func(context.Context, connection.ConnectionConfig, jvm.ChangeRequest) (jvm.ApplyResult, error) previewReq *jvm.ChangeRequest applyReq *jvm.ChangeRequest } @@ -51,10 +54,13 @@ func (f fakeJVMProvider) PreviewChange(_ context.Context, _ connection.Connectio } return f.preview, f.previewErr } -func (f fakeJVMProvider) ApplyChange(_ context.Context, _ connection.ConnectionConfig, req jvm.ChangeRequest) (jvm.ApplyResult, error) { +func (f fakeJVMProvider) ApplyChange(ctx context.Context, cfg connection.ConnectionConfig, req jvm.ChangeRequest) (jvm.ApplyResult, error) { if f.applyReq != nil { *f.applyReq = req } + if f.applyFn != nil { + return f.applyFn(ctx, cfg, req) + } return f.apply, f.applyErr } @@ -64,6 +70,21 @@ func swapJVMProviderFactory(factory func(mode string) (jvm.Provider, error)) fun return func() { newJVMProvider = prev } } +func forceAuditAppendFailureAfterPending(t *testing.T, auditDir string) { + t.Helper() + + auditPath := filepath.Join(auditDir, "jvm_audit.jsonl") + if err := os.Remove(auditPath); err != nil && !errors.Is(err, os.ErrNotExist) { + t.Fatalf("Remove audit file returned error: %v", err) + } + if err := os.RemoveAll(auditDir); err != nil { + t.Fatalf("RemoveAll audit dir returned error: %v", err) + } + if err := os.WriteFile(auditDir, []byte("blocker"), 0o600); err != nil { + t.Fatalf("WriteFile audit dir blocker returned error: %v", err) + } +} + func TestTestJVMConnectionUsesPreferredProvider(t *testing.T) { app := NewAppWithSecretStore(nil) var gotMode string @@ -457,6 +478,65 @@ func TestJVMGetValueReturnsProviderPayload(t *testing.T) { } } +func TestJVMApplyChangeRequiresConfirmationTokenForHighRiskPreview(t *testing.T) { + app := NewAppWithSecretStore(nil) + app.configDir = t.TempDir() + readOnly := false + var applyReq jvm.ChangeRequest + restore := swapJVMProviderFactory(func(mode string) (jvm.Provider, error) { + return fakeJVMProvider{ + value: jvm.ValueSnapshot{ + ResourceID: "/cache/orders", + Kind: "entry", + Format: "json", + Value: map[string]any{ + "status": "stale", + }, + }, + previewSet: true, + preview: jvm.ChangePreview{ + Allowed: true, + Summary: "risky change", + RiskLevel: "high", + }, + applyReq: &applyReq, + apply: jvm.ApplyResult{ + Status: "applied", + }, + }, nil + }) + defer restore() + + res := app.JVMApplyChange(connection.ConnectionConfig{ + Type: "jvm", + ID: "conn-orders", + Host: "orders.internal", + JVM: connection.JVMConfig{ + ReadOnly: &readOnly, + PreferredMode: "jmx", + AllowedModes: []string{"jmx"}, + }, + }, jvm.ChangeRequest{ + ProviderMode: "jmx", + ResourceID: "/cache/orders", + Action: "put", + Reason: "repair cache", + Payload: map[string]any{ + "status": "ready", + }, + }) + + if res.Success { + t.Fatalf("expected missing confirmation token to fail, got %+v", res) + } + if !strings.Contains(res.Message, "确认") && !strings.Contains(res.Message, "重新预览") { + t.Fatalf("expected confirmation guidance message, got %q", res.Message) + } + if applyReq.ResourceID != "" { + t.Fatalf("expected provider ApplyChange not to run, got %#v", applyReq) + } +} + func TestJVMApplyChangeReturnsProviderPayload(t *testing.T) { app := NewAppWithSecretStore(nil) app.configDir = t.TempDir() @@ -521,6 +601,324 @@ func TestJVMApplyChangeReturnsProviderPayload(t *testing.T) { } } +func TestJVMApplyChangePreviewTokenAllowsConfirmedApply(t *testing.T) { + app := NewAppWithSecretStore(nil) + app.configDir = t.TempDir() + readOnly := false + + restore := swapJVMProviderFactory(func(mode string) (jvm.Provider, error) { + return fakeJVMProvider{ + value: jvm.ValueSnapshot{ + ResourceID: "/cache/orders", + Kind: "entry", + Format: "json", + Value: map[string]any{ + "status": "stale", + }, + }, + previewSet: true, + preview: jvm.ChangePreview{ + Allowed: true, + RequiresConfirmation: true, + Summary: "risky change", + RiskLevel: "high", + }, + apply: jvm.ApplyResult{ + Status: "applied", + UpdatedValue: jvm.ValueSnapshot{ + ResourceID: "/cache/orders", + Kind: "entry", + Format: "json", + Value: map[string]any{ + "status": "ready", + }, + }, + }, + }, nil + }) + defer restore() + + cfg := connection.ConnectionConfig{ + Type: "jvm", + ID: "conn-orders", + Host: "orders.internal", + JVM: connection.JVMConfig{ + Environment: jvm.EnvPROD, + ReadOnly: &readOnly, + PreferredMode: "jmx", + AllowedModes: []string{"jmx"}, + }, + } + + req := jvm.ChangeRequest{ + ProviderMode: "jmx", + ResourceID: "/cache/orders", + Action: "put", + Reason: "repair cache", + Payload: map[string]any{ + "status": "ready", + }, + } + + previewRes := app.JVMPreviewChange(cfg, req) + if !previewRes.Success { + t.Fatalf("expected preview success, got %+v", previewRes) + } + preview, ok := previewRes.Data.(jvm.ChangePreview) + if !ok { + t.Fatalf("expected preview payload, got %#v", previewRes.Data) + } + if strings.TrimSpace(preview.ConfirmationToken) == "" { + t.Fatalf("expected confirmation token, got %#v", preview) + } + + req.ConfirmationToken = preview.ConfirmationToken + applyRes := app.JVMApplyChange(cfg, req) + if !applyRes.Success { + t.Fatalf("expected apply success with confirmation token, got %+v", applyRes) + } + + listRes := app.JVMListAuditRecords("conn-orders", 10) + if !listRes.Success { + t.Fatalf("expected audit list success, got %+v", listRes) + } + records, ok := listRes.Data.([]jvm.AuditRecord) + if !ok { + t.Fatalf("expected audit record slice, got %#v", listRes.Data) + } + var hasPending, hasApplied bool + for _, record := range records { + switch record.Result { + case "pending": + hasPending = true + case "applied": + hasApplied = true + } + } + if !hasPending || !hasApplied { + t.Fatalf("expected pending+applied records, got %#v", records) + } +} + +func TestJVMApplyChangeRejectsUnissuedDeterministicConfirmationToken(t *testing.T) { + app := NewAppWithSecretStore(nil) + app.configDir = t.TempDir() + readOnly := false + var applyReq jvm.ChangeRequest + + provider := fakeJVMProvider{ + value: jvm.ValueSnapshot{ + ResourceID: "/cache/orders", + Kind: "entry", + Format: "json", + Value: map[string]any{ + "status": "stale", + }, + }, + previewSet: true, + preview: jvm.ChangePreview{ + Allowed: true, + RequiresConfirmation: true, + Summary: "risky change", + RiskLevel: "high", + }, + applyReq: &applyReq, + apply: jvm.ApplyResult{ + Status: "applied", + }, + } + restore := swapJVMProviderFactory(func(mode string) (jvm.Provider, error) { + return provider, nil + }) + defer restore() + + cfg := connection.ConnectionConfig{ + Type: "jvm", + ID: "conn-orders", + Host: "orders.internal", + JVM: connection.JVMConfig{ + Environment: jvm.EnvPROD, + ReadOnly: &readOnly, + PreferredMode: "jmx", + AllowedModes: []string{"jmx"}, + }, + } + req := jvm.ChangeRequest{ + ProviderMode: "jmx", + ResourceID: "/cache/orders", + Action: "put", + Reason: "repair cache", + Payload: map[string]any{ + "status": "ready", + }, + } + + preview, err := jvm.BuildChangePreview(context.Background(), provider, cfg, req) + if err != nil { + t.Fatalf("BuildChangePreview returned error: %v", err) + } + if strings.TrimSpace(preview.ConfirmationToken) == "" { + t.Fatalf("expected deterministic preview token, got %#v", preview) + } + + req.ConfirmationToken = preview.ConfirmationToken + res := app.JVMApplyChange(cfg, req) + if res.Success { + t.Fatalf("expected unissued confirmation token to fail, got %+v", res) + } + if applyReq.ResourceID != "" { + t.Fatalf("expected provider ApplyChange not to run, got %#v", applyReq) + } +} + +func TestJVMApplyChangeRejectsReplayedPreviewConfirmationToken(t *testing.T) { + app := NewAppWithSecretStore(nil) + app.configDir = t.TempDir() + readOnly := false + applyCalls := 0 + + restore := swapJVMProviderFactory(func(mode string) (jvm.Provider, error) { + return fakeJVMProvider{ + value: jvm.ValueSnapshot{ + ResourceID: "/cache/orders", + Kind: "entry", + Format: "json", + Value: map[string]any{ + "status": "stale", + }, + }, + previewSet: true, + preview: jvm.ChangePreview{ + Allowed: true, + RequiresConfirmation: true, + Summary: "risky change", + RiskLevel: "high", + }, + applyFn: func(context.Context, connection.ConnectionConfig, jvm.ChangeRequest) (jvm.ApplyResult, error) { + applyCalls++ + return jvm.ApplyResult{Status: "applied"}, nil + }, + }, nil + }) + defer restore() + + cfg := connection.ConnectionConfig{ + Type: "jvm", + ID: "conn-orders", + Host: "orders.internal", + JVM: connection.JVMConfig{ + Environment: jvm.EnvPROD, + ReadOnly: &readOnly, + PreferredMode: "jmx", + AllowedModes: []string{"jmx"}, + }, + } + req := jvm.ChangeRequest{ + ProviderMode: "jmx", + ResourceID: "/cache/orders", + Action: "put", + Reason: "repair cache", + Payload: map[string]any{ + "status": "ready", + }, + } + + previewRes := app.JVMPreviewChange(cfg, req) + if !previewRes.Success { + t.Fatalf("expected preview success, got %+v", previewRes) + } + preview, ok := previewRes.Data.(jvm.ChangePreview) + if !ok { + t.Fatalf("expected preview payload, got %#v", previewRes.Data) + } + req.ConfirmationToken = preview.ConfirmationToken + + firstRes := app.JVMApplyChange(cfg, req) + if !firstRes.Success { + t.Fatalf("expected first apply success, got %+v", firstRes) + } + secondRes := app.JVMApplyChange(cfg, req) + if secondRes.Success { + t.Fatalf("expected replayed confirmation token to fail, got %+v", secondRes) + } + if applyCalls != 1 { + t.Fatalf("expected exactly one provider ApplyChange call, got %d", applyCalls) + } +} + +func TestJVMApplyChangeRejectsExpiredPreviewConfirmationToken(t *testing.T) { + app := NewAppWithSecretStore(nil) + app.configDir = t.TempDir() + app.jvmPreviewTokenTTL = time.Nanosecond + readOnly := false + applyCalls := 0 + + restore := swapJVMProviderFactory(func(mode string) (jvm.Provider, error) { + return fakeJVMProvider{ + value: jvm.ValueSnapshot{ + ResourceID: "/cache/orders", + Kind: "entry", + Format: "json", + Value: map[string]any{ + "status": "stale", + }, + }, + previewSet: true, + preview: jvm.ChangePreview{ + Allowed: true, + RequiresConfirmation: true, + Summary: "risky change", + RiskLevel: "high", + }, + applyFn: func(context.Context, connection.ConnectionConfig, jvm.ChangeRequest) (jvm.ApplyResult, error) { + applyCalls++ + return jvm.ApplyResult{Status: "applied"}, nil + }, + }, nil + }) + defer restore() + + cfg := connection.ConnectionConfig{ + Type: "jvm", + ID: "conn-orders", + Host: "orders.internal", + JVM: connection.JVMConfig{ + Environment: jvm.EnvPROD, + ReadOnly: &readOnly, + PreferredMode: "jmx", + AllowedModes: []string{"jmx"}, + }, + } + req := jvm.ChangeRequest{ + ProviderMode: "jmx", + ResourceID: "/cache/orders", + Action: "put", + Reason: "repair cache", + Payload: map[string]any{ + "status": "ready", + }, + } + + previewRes := app.JVMPreviewChange(cfg, req) + if !previewRes.Success { + t.Fatalf("expected preview success, got %+v", previewRes) + } + preview, ok := previewRes.Data.(jvm.ChangePreview) + if !ok { + t.Fatalf("expected preview payload, got %#v", previewRes.Data) + } + time.Sleep(time.Millisecond) + req.ConfirmationToken = preview.ConfirmationToken + + res := app.JVMApplyChange(cfg, req) + if res.Success { + t.Fatalf("expected expired confirmation token to fail, got %+v", res) + } + if applyCalls != 0 { + t.Fatalf("expected provider ApplyChange not to run, got %d calls", applyCalls) + } +} + func TestJVMApplyChangePersistsAuditSource(t *testing.T) { app := NewAppWithSecretStore(nil) app.configDir = t.TempDir() @@ -578,11 +976,23 @@ func TestJVMApplyChangePersistsAuditSource(t *testing.T) { t.Fatalf("expected audit list success, got %+v", listRes) } records, ok := listRes.Data.([]jvm.AuditRecord) - if !ok || len(records) != 1 { - t.Fatalf("expected one audit record, got %#v", listRes.Data) + if !ok || len(records) < 2 { + t.Fatalf("expected at least two audit records (pending+terminal), got %#v", listRes.Data) } - if records[0].Source != "ai-plan" { - t.Fatalf("expected audit source %q, got %#v", "ai-plan", records[0]) + var hasPending, hasApplied bool + for _, record := range records { + if record.Source != "ai-plan" { + t.Fatalf("expected audit source %q, got %#v", "ai-plan", record) + } + switch record.Result { + case "pending": + hasPending = true + case "applied": + hasApplied = true + } + } + if !hasPending || !hasApplied { + t.Fatalf("expected pending and applied audit records, got %#v", records) } } @@ -647,11 +1057,20 @@ func TestJVMApplyChangeNormalizesRequestBeforeProviderAndAudit(t *testing.T) { t.Fatalf("expected audit list success, got %+v", listRes) } records, ok := listRes.Data.([]jvm.AuditRecord) - if !ok || len(records) != 1 { - t.Fatalf("expected one audit record, got %#v", listRes.Data) + if !ok || len(records) < 2 { + t.Fatalf("expected at least two audit records (pending+terminal), got %#v", listRes.Data) } - if records[0].ProviderMode != "endpoint" || records[0].ResourceID != "/cache/orders" || records[0].Action != "put" || records[0].Reason != "repair cache" || records[0].Source != "manual" { - t.Fatalf("expected normalized audit record, got %#v", records[0]) + var matchedTerminal bool + for _, record := range records { + if record.ProviderMode != "endpoint" || record.ResourceID != "/cache/orders" || record.Action != "put" || record.Reason != "repair cache" || record.Source != "manual" { + t.Fatalf("expected normalized audit record, got %#v", record) + } + if record.Result == "applied" { + matchedTerminal = true + } + } + if !matchedTerminal { + t.Fatalf("expected applied terminal audit record, got %#v", records) } } @@ -711,7 +1130,7 @@ func TestJVMListAuditRecordsReturnsLatestRecords(t *testing.T) { } } -func TestJVMApplyChangeSurfacesAuditWriteFailure(t *testing.T) { +func TestJVMApplyChangeFailsClosedWhenInitialAuditWriteFails(t *testing.T) { app := NewAppWithSecretStore(nil) tempDir := t.TempDir() blockerPath := filepath.Join(tempDir, "audit-blocker") @@ -721,6 +1140,7 @@ func TestJVMApplyChangeSurfacesAuditWriteFailure(t *testing.T) { app.configDir = blockerPath readOnly := false + var applyReq jvm.ChangeRequest restore := swapJVMProviderFactory(func(mode string) (jvm.Provider, error) { return fakeJVMProvider{ value: jvm.ValueSnapshot{ @@ -731,6 +1151,7 @@ func TestJVMApplyChangeSurfacesAuditWriteFailure(t *testing.T) { "status": "stale", }, }, + applyReq: &applyReq, apply: jvm.ApplyResult{ Status: "applied", }, @@ -757,14 +1178,706 @@ func TestJVMApplyChangeSurfacesAuditWriteFailure(t *testing.T) { }, }) + if res.Success { + t.Fatalf("expected fail-closed when initial audit write fails, got %+v", res) + } + if !strings.Contains(res.Message, "审计") { + t.Fatalf("expected audit failure message, got %q", res.Message) + } + if applyReq.ResourceID != "" { + t.Fatalf("expected provider ApplyChange not to run, got %#v", applyReq) + } +} + +func TestJVMApplyChangeLatestAuditRecordIsTerminal(t *testing.T) { + app := NewAppWithSecretStore(nil) + app.configDir = t.TempDir() + readOnly := false + + restore := swapJVMProviderFactory(func(mode string) (jvm.Provider, error) { + return fakeJVMProvider{ + value: jvm.ValueSnapshot{ + ResourceID: "/cache/orders", + Kind: "entry", + Format: "json", + Value: map[string]any{ + "status": "stale", + }, + }, + apply: jvm.ApplyResult{Status: "applied"}, + }, nil + }) + defer restore() + + res := app.JVMApplyChange(connection.ConnectionConfig{ + Type: "jvm", + ID: "conn-orders", + Host: "orders.internal", + JVM: connection.JVMConfig{ + ReadOnly: &readOnly, + PreferredMode: "jmx", + AllowedModes: []string{"jmx"}, + }, + }, jvm.ChangeRequest{ + ProviderMode: "jmx", + ResourceID: "/cache/orders", + Action: "put", + Reason: "repair cache", + Payload: map[string]any{ + "status": "ready", + }, + }) if !res.Success { - t.Fatalf("expected success despite audit failure, got %+v", res) + t.Fatalf("expected apply success, got %+v", res) + } + + latestRes := app.JVMListAuditRecords("conn-orders", 1) + if !latestRes.Success { + t.Fatalf("expected list success, got %+v", latestRes) + } + latestRecords, ok := latestRes.Data.([]jvm.AuditRecord) + if !ok || len(latestRecords) != 1 { + t.Fatalf("expected one latest audit record, got %#v", latestRes.Data) + } + if latestRecords[0].Result != "applied" { + t.Fatalf("expected latest record applied, got %#v", latestRecords[0]) + } +} + +func TestJVMApplyChangeApplySuccessKeepsSuccessWhenTerminalAuditFails(t *testing.T) { + app := NewAppWithSecretStore(nil) + tempDir := t.TempDir() + auditDir := filepath.Join(tempDir, "audit") + if err := os.MkdirAll(auditDir, 0o755); err != nil { + t.Fatalf("MkdirAll returned error: %v", err) + } + app.configDir = auditDir + + readOnly := false + terminalAuditFailed := false + restore := swapJVMProviderFactory(func(mode string) (jvm.Provider, error) { + return fakeJVMProvider{ + value: jvm.ValueSnapshot{ + ResourceID: "/cache/orders", + Kind: "entry", + Format: "json", + }, + applyFn: func(_ context.Context, _ connection.ConnectionConfig, _ jvm.ChangeRequest) (jvm.ApplyResult, error) { + if !terminalAuditFailed { + terminalAuditFailed = true + forceAuditAppendFailureAfterPending(t, auditDir) + } + return jvm.ApplyResult{Status: "applied", Message: "ok"}, nil + }, + }, nil + }) + defer restore() + + res := app.JVMApplyChange(connection.ConnectionConfig{ + Type: "jvm", + ID: "conn-orders", + Host: "orders.internal", + JVM: connection.JVMConfig{ + ReadOnly: &readOnly, + PreferredMode: "jmx", + AllowedModes: []string{"jmx"}, + }, + }, jvm.ChangeRequest{ + ProviderMode: "jmx", + ResourceID: "/cache/orders", + Action: "put", + Reason: "repair cache", + Payload: map[string]any{ + "status": "ready", + }, + }) + + if !res.Success { + t.Fatalf("expected success when apply succeeded, got %+v", res) } result, ok := res.Data.(jvm.ApplyResult) if !ok { - t.Fatalf("expected apply result, got %#v", res.Data) + t.Fatalf("expected apply result data, got %#v", res.Data) } - if !strings.Contains(result.Message, "审计记录写入失败") { - t.Fatalf("expected audit failure message, got %#v", result) + if result.Status != "applied" { + t.Fatalf("expected applied status, got %#v", result) + } + if !strings.Contains(result.Message, "终态审计写入失败") { + t.Fatalf("expected terminal audit warning in result message, got %#v", result) + } +} + +func TestJVMApplyChangeApplyFailureReportsFailedAuditWriteError(t *testing.T) { + app := NewAppWithSecretStore(nil) + tempDir := t.TempDir() + auditDir := filepath.Join(tempDir, "audit") + if err := os.MkdirAll(auditDir, 0o755); err != nil { + t.Fatalf("MkdirAll returned error: %v", err) + } + app.configDir = auditDir + + readOnly := false + failedAuditBlocked := false + restore := swapJVMProviderFactory(func(mode string) (jvm.Provider, error) { + return fakeJVMProvider{ + value: jvm.ValueSnapshot{ + ResourceID: "/cache/orders", + Kind: "entry", + Format: "json", + }, + applyFn: func(_ context.Context, _ connection.ConnectionConfig, _ jvm.ChangeRequest) (jvm.ApplyResult, error) { + if !failedAuditBlocked { + failedAuditBlocked = true + forceAuditAppendFailureAfterPending(t, auditDir) + } + return jvm.ApplyResult{}, errors.New("provider apply failed") + }, + }, nil + }) + defer restore() + + res := app.JVMApplyChange(connection.ConnectionConfig{ + Type: "jvm", + ID: "conn-orders", + Host: "orders.internal", + JVM: connection.JVMConfig{ + ReadOnly: &readOnly, + PreferredMode: "jmx", + AllowedModes: []string{"jmx"}, + }, + }, jvm.ChangeRequest{ + ProviderMode: "jmx", + ResourceID: "/cache/orders", + Action: "put", + Reason: "repair cache", + Payload: map[string]any{ + "status": "ready", + }, + }) + + if res.Success { + t.Fatalf("expected failure when apply fails, got %+v", res) + } + if !strings.Contains(res.Message, "provider apply failed") { + t.Fatalf("expected provider failure in message, got %q", res.Message) + } + if !strings.Contains(res.Message, "失败审计写入失败") { + t.Fatalf("expected failed audit write failure in message, got %q", res.Message) + } +} + +func TestJVMApplyChangeApplyFailureKeepsProviderErrorWhenFailedAuditSucceeds(t *testing.T) { + app := NewAppWithSecretStore(nil) + app.configDir = t.TempDir() + readOnly := false + + restore := swapJVMProviderFactory(func(mode string) (jvm.Provider, error) { + return fakeJVMProvider{ + value: jvm.ValueSnapshot{ + ResourceID: "/cache/orders", + Kind: "entry", + Format: "json", + }, + applyErr: errors.New("provider apply failed"), + }, nil + }) + defer restore() + + res := app.JVMApplyChange(connection.ConnectionConfig{ + Type: "jvm", + ID: "conn-orders", + Host: "orders.internal", + JVM: connection.JVMConfig{ + ReadOnly: &readOnly, + PreferredMode: "jmx", + AllowedModes: []string{"jmx"}, + }, + }, jvm.ChangeRequest{ + ProviderMode: "jmx", + ResourceID: "/cache/orders", + Action: "put", + Reason: "repair cache", + Payload: map[string]any{ + "status": "ready", + }, + }) + + if res.Success { + t.Fatalf("expected failure when provider apply fails, got %+v", res) + } + if res.Message != "provider apply failed" { + t.Fatalf("expected provider failure only when failed audit succeeds, got %q", res.Message) + } +} + +func TestJVMApplyChangeUsesProviderErrorWhenFailedAuditAlsoFails(t *testing.T) { + app := NewAppWithSecretStore(nil) + tempDir := t.TempDir() + auditDir := filepath.Join(tempDir, "audit") + if err := os.MkdirAll(auditDir, 0o755); err != nil { + t.Fatalf("MkdirAll returned error: %v", err) + } + app.configDir = auditDir + + readOnly := false + restore := swapJVMProviderFactory(func(mode string) (jvm.Provider, error) { + return fakeJVMProvider{ + value: jvm.ValueSnapshot{ + ResourceID: "/cache/orders", + Kind: "entry", + Format: "json", + }, + applyFn: func(_ context.Context, _ connection.ConnectionConfig, _ jvm.ChangeRequest) (jvm.ApplyResult, error) { + forceAuditAppendFailureAfterPending(t, auditDir) + return jvm.ApplyResult{}, errors.New("provider apply failed") + }, + }, nil + }) + defer restore() + + res := app.JVMApplyChange(connection.ConnectionConfig{ + Type: "jvm", + ID: "conn-orders", + Host: "orders.internal", + JVM: connection.JVMConfig{ + ReadOnly: &readOnly, + PreferredMode: "jmx", + AllowedModes: []string{"jmx"}, + }, + }, jvm.ChangeRequest{ + ProviderMode: "jmx", + ResourceID: "/cache/orders", + Action: "put", + Reason: "repair cache", + Payload: map[string]any{ + "status": "ready", + }, + }) + + if res.Success { + t.Fatalf("expected failure when provider apply fails, got %+v", res) + } + if !strings.HasPrefix(res.Message, "provider apply failed") { + t.Fatalf("expected provider error prefix, got %q", res.Message) + } + if !strings.Contains(res.Message, "失败审计写入失败") { + t.Fatalf("expected failed audit write failure in message, got %q", res.Message) + } +} + +func TestJVMApplyChangeTerminalAuditWarningAppendsToExistingResultMessage(t *testing.T) { + app := NewAppWithSecretStore(nil) + tempDir := t.TempDir() + auditDir := filepath.Join(tempDir, "audit") + if err := os.MkdirAll(auditDir, 0o755); err != nil { + t.Fatalf("MkdirAll returned error: %v", err) + } + app.configDir = auditDir + + readOnly := false + terminalAuditFailed := false + restore := swapJVMProviderFactory(func(mode string) (jvm.Provider, error) { + return fakeJVMProvider{ + value: jvm.ValueSnapshot{ResourceID: "/cache/orders", Kind: "entry", Format: "json"}, + applyFn: func(_ context.Context, _ connection.ConnectionConfig, _ jvm.ChangeRequest) (jvm.ApplyResult, error) { + if !terminalAuditFailed { + terminalAuditFailed = true + forceAuditAppendFailureAfterPending(t, auditDir) + } + return jvm.ApplyResult{Status: "applied", Message: "provider message"}, nil + }, + }, nil + }) + defer restore() + + res := app.JVMApplyChange(connection.ConnectionConfig{ + Type: "jvm", + ID: "conn-orders", + Host: "orders.internal", + JVM: connection.JVMConfig{ReadOnly: &readOnly, PreferredMode: "jmx", AllowedModes: []string{"jmx"}}, + }, jvm.ChangeRequest{ + ProviderMode: "jmx", + ResourceID: "/cache/orders", + Action: "put", + Reason: "repair cache", + Payload: map[string]any{"status": "ready"}, + }) + if !res.Success { + t.Fatalf("expected success when apply succeeded, got %+v", res) + } + result, ok := res.Data.(jvm.ApplyResult) + if !ok { + t.Fatalf("expected apply result data, got %#v", res.Data) + } + if !strings.Contains(result.Message, "provider message") || !strings.Contains(result.Message, "终态审计写入失败") { + t.Fatalf("expected provider message with terminal audit warning, got %#v", result) + } +} + +func TestJVMApplyChangeTerminalAuditWarningUsesStandaloneMessageWhenResultMessageEmpty(t *testing.T) { + app := NewAppWithSecretStore(nil) + tempDir := t.TempDir() + auditDir := filepath.Join(tempDir, "audit") + if err := os.MkdirAll(auditDir, 0o755); err != nil { + t.Fatalf("MkdirAll returned error: %v", err) + } + app.configDir = auditDir + + readOnly := false + terminalAuditFailed := false + restore := swapJVMProviderFactory(func(mode string) (jvm.Provider, error) { + return fakeJVMProvider{ + value: jvm.ValueSnapshot{ResourceID: "/cache/orders", Kind: "entry", Format: "json"}, + applyFn: func(_ context.Context, _ connection.ConnectionConfig, _ jvm.ChangeRequest) (jvm.ApplyResult, error) { + if !terminalAuditFailed { + terminalAuditFailed = true + forceAuditAppendFailureAfterPending(t, auditDir) + } + return jvm.ApplyResult{Status: "applied"}, nil + }, + }, nil + }) + defer restore() + + res := app.JVMApplyChange(connection.ConnectionConfig{ + Type: "jvm", + ID: "conn-orders", + Host: "orders.internal", + JVM: connection.JVMConfig{ReadOnly: &readOnly, PreferredMode: "jmx", AllowedModes: []string{"jmx"}}, + }, jvm.ChangeRequest{ + ProviderMode: "jmx", + ResourceID: "/cache/orders", + Action: "put", + Reason: "repair cache", + Payload: map[string]any{"status": "ready"}, + }) + if !res.Success { + t.Fatalf("expected success when apply succeeded, got %+v", res) + } + result, ok := res.Data.(jvm.ApplyResult) + if !ok { + t.Fatalf("expected apply result data, got %#v", res.Data) + } + if !strings.Contains(result.Message, "终态审计写入失败") { + t.Fatalf("expected standalone terminal audit warning, got %#v", result) + } +} + +func TestJVMApplyChangeFailedAuditFailureMessageIncludesUnderlyingError(t *testing.T) { + app := NewAppWithSecretStore(nil) + tempDir := t.TempDir() + auditDir := filepath.Join(tempDir, "audit") + if err := os.MkdirAll(auditDir, 0o755); err != nil { + t.Fatalf("MkdirAll returned error: %v", err) + } + app.configDir = auditDir + + readOnly := false + restore := swapJVMProviderFactory(func(mode string) (jvm.Provider, error) { + return fakeJVMProvider{ + value: jvm.ValueSnapshot{ResourceID: "/cache/orders", Kind: "entry", Format: "json"}, + applyFn: func(_ context.Context, _ connection.ConnectionConfig, _ jvm.ChangeRequest) (jvm.ApplyResult, error) { + forceAuditAppendFailureAfterPending(t, auditDir) + return jvm.ApplyResult{}, errors.New("provider apply failed") + }, + }, nil + }) + defer restore() + + res := app.JVMApplyChange(connection.ConnectionConfig{ + Type: "jvm", + ID: "conn-orders", + Host: "orders.internal", + JVM: connection.JVMConfig{ReadOnly: &readOnly, PreferredMode: "jmx", AllowedModes: []string{"jmx"}}, + }, jvm.ChangeRequest{ + ProviderMode: "jmx", + ResourceID: "/cache/orders", + Action: "put", + Reason: "repair cache", + Payload: map[string]any{"status": "ready"}, + }) + if res.Success { + t.Fatalf("expected failure when apply fails, got %+v", res) + } + if !strings.Contains(res.Message, "失败审计写入失败") { + t.Fatalf("expected failed audit failure marker, got %q", res.Message) + } + if !strings.Contains(strings.ToLower(res.Message), "not a directory") { + t.Fatalf("expected underlying audit failure detail in message, got %q", res.Message) + } +} + +func TestJVMApplyChangeFailureMessageSeparatorUsesChineseSemicolon(t *testing.T) { + app := NewAppWithSecretStore(nil) + tempDir := t.TempDir() + auditDir := filepath.Join(tempDir, "audit") + if err := os.MkdirAll(auditDir, 0o755); err != nil { + t.Fatalf("MkdirAll returned error: %v", err) + } + app.configDir = auditDir + + readOnly := false + restore := swapJVMProviderFactory(func(mode string) (jvm.Provider, error) { + return fakeJVMProvider{ + value: jvm.ValueSnapshot{ResourceID: "/cache/orders", Kind: "entry", Format: "json"}, + applyFn: func(_ context.Context, _ connection.ConnectionConfig, _ jvm.ChangeRequest) (jvm.ApplyResult, error) { + forceAuditAppendFailureAfterPending(t, auditDir) + return jvm.ApplyResult{}, errors.New("provider apply failed") + }, + }, nil + }) + defer restore() + + res := app.JVMApplyChange(connection.ConnectionConfig{ + Type: "jvm", + ID: "conn-orders", + Host: "orders.internal", + JVM: connection.JVMConfig{ReadOnly: &readOnly, PreferredMode: "jmx", AllowedModes: []string{"jmx"}}, + }, jvm.ChangeRequest{ + ProviderMode: "jmx", + ResourceID: "/cache/orders", + Action: "put", + Reason: "repair cache", + Payload: map[string]any{"status": "ready"}, + }) + if res.Success { + t.Fatalf("expected failure when apply fails, got %+v", res) + } + if !strings.Contains(res.Message, ";失败审计写入失败") { + t.Fatalf("expected chinese semicolon separator in failure message, got %q", res.Message) + } +} + +func TestJVMApplyChangeLatestAuditRecordIsFailedWhenApplyFails(t *testing.T) { + app := NewAppWithSecretStore(nil) + app.configDir = t.TempDir() + readOnly := false + + restore := swapJVMProviderFactory(func(mode string) (jvm.Provider, error) { + return fakeJVMProvider{ + value: jvm.ValueSnapshot{ResourceID: "/cache/orders", Kind: "entry", Format: "json"}, + applyErr: errors.New("provider apply failed"), + }, nil + }) + defer restore() + + res := app.JVMApplyChange(connection.ConnectionConfig{ + Type: "jvm", + ID: "conn-orders", + Host: "orders.internal", + JVM: connection.JVMConfig{ReadOnly: &readOnly, PreferredMode: "jmx", AllowedModes: []string{"jmx"}}, + }, jvm.ChangeRequest{ + ProviderMode: "jmx", + ResourceID: "/cache/orders", + Action: "put", + Reason: "repair cache", + Payload: map[string]any{"status": "ready"}, + }) + if res.Success { + t.Fatalf("expected apply failure, got %+v", res) + } + + latestRes := app.JVMListAuditRecords("conn-orders", 10) + if !latestRes.Success { + t.Fatalf("expected list success, got %+v", latestRes) + } + records, ok := latestRes.Data.([]jvm.AuditRecord) + if !ok { + t.Fatalf("expected records slice, got %#v", latestRes.Data) + } + if len(records) < 2 { + t.Fatalf("expected at least pending and failed records, got %#v", records) + } + if records[0].Result != "failed" { + t.Fatalf("expected latest record failed, got %#v", records[0]) + } + + var pendingTs, failedTs int64 + for _, record := range records { + switch record.Result { + case "pending": + pendingTs = record.Timestamp + case "failed": + failedTs = record.Timestamp + } + } + if pendingTs == 0 || failedTs == 0 { + t.Fatalf("expected pending and failed records, got %#v", records) + } + if failedTs <= pendingTs { + t.Fatalf("expected failed timestamp > pending timestamp, pending=%d failed=%d records=%#v", pendingTs, failedTs, records) + } +} + +func TestJVMApplyChangePendingAndTerminalAuditTimestampsAreStrictlyIncreasing(t *testing.T) { + app := NewAppWithSecretStore(nil) + app.configDir = t.TempDir() + readOnly := false + + restore := swapJVMProviderFactory(func(mode string) (jvm.Provider, error) { + return fakeJVMProvider{ + value: jvm.ValueSnapshot{ResourceID: "/cache/orders", Kind: "entry", Format: "json"}, + apply: jvm.ApplyResult{Status: "applied"}, + }, nil + }) + defer restore() + + res := app.JVMApplyChange(connection.ConnectionConfig{ + Type: "jvm", + ID: "conn-orders", + Host: "orders.internal", + JVM: connection.JVMConfig{ReadOnly: &readOnly, PreferredMode: "jmx", AllowedModes: []string{"jmx"}}, + }, jvm.ChangeRequest{ + ProviderMode: "jmx", + ResourceID: "/cache/orders", + Action: "put", + Reason: "repair cache", + Payload: map[string]any{"status": "ready"}, + }) + if !res.Success { + t.Fatalf("expected apply success, got %+v", res) + } + + listRes := app.JVMListAuditRecords("conn-orders", 10) + if !listRes.Success { + t.Fatalf("expected list success, got %+v", listRes) + } + records, ok := listRes.Data.([]jvm.AuditRecord) + if !ok { + t.Fatalf("expected records slice, got %#v", listRes.Data) + } + var pendingTs, terminalTs int64 + for _, record := range records { + switch record.Result { + case "pending": + pendingTs = record.Timestamp + case "applied": + terminalTs = record.Timestamp + } + } + if pendingTs == 0 || terminalTs == 0 { + t.Fatalf("expected pending and applied records, got %#v", records) + } + if terminalTs <= pendingTs { + t.Fatalf("expected terminal timestamp > pending timestamp, pending=%d terminal=%d records=%#v", pendingTs, terminalTs, records) + } +} + +func TestJVMApplyChangeTerminalAuditTimestampReflectsApplyCompletion(t *testing.T) { + app := NewAppWithSecretStore(nil) + app.configDir = t.TempDir() + readOnly := false + + restore := swapJVMProviderFactory(func(mode string) (jvm.Provider, error) { + return fakeJVMProvider{ + value: jvm.ValueSnapshot{ResourceID: "/cache/orders", Kind: "entry", Format: "json"}, + applyFn: func(_ context.Context, _ connection.ConnectionConfig, _ jvm.ChangeRequest) (jvm.ApplyResult, error) { + time.Sleep(15 * time.Millisecond) + return jvm.ApplyResult{Status: "applied"}, nil + }, + }, nil + }) + defer restore() + + res := app.JVMApplyChange(connection.ConnectionConfig{ + Type: "jvm", + ID: "conn-orders", + Host: "orders.internal", + JVM: connection.JVMConfig{ReadOnly: &readOnly, PreferredMode: "jmx", AllowedModes: []string{"jmx"}}, + }, jvm.ChangeRequest{ + ProviderMode: "jmx", + ResourceID: "/cache/orders", + Action: "put", + Reason: "repair cache delayed", + Payload: map[string]any{"status": "ready"}, + }) + if !res.Success { + t.Fatalf("expected apply success, got %+v", res) + } + + listRes := app.JVMListAuditRecords("conn-orders", 10) + if !listRes.Success { + t.Fatalf("expected list success, got %+v", listRes) + } + records, ok := listRes.Data.([]jvm.AuditRecord) + if !ok { + t.Fatalf("expected records slice, got %#v", listRes.Data) + } + var pendingTs, terminalTs int64 + for _, record := range records { + if record.Reason != "repair cache delayed" { + continue + } + switch record.Result { + case "pending": + pendingTs = record.Timestamp + case "applied": + terminalTs = record.Timestamp + } + } + if pendingTs == 0 || terminalTs == 0 { + t.Fatalf("expected pending and applied records for delayed apply, got %#v", records) + } + if terminalTs <= pendingTs+1 { + t.Fatalf("expected delayed terminal timestamp to be strictly greater than pending+1, pending=%d terminal=%d records=%#v", pendingTs, terminalTs, records) + } +} + +func TestJVMApplyChangeTimestampGuaranteeHoldsAcrossMultipleApplies(t *testing.T) { + app := NewAppWithSecretStore(nil) + app.configDir = t.TempDir() + readOnly := false + + restore := swapJVMProviderFactory(func(mode string) (jvm.Provider, error) { + return fakeJVMProvider{ + value: jvm.ValueSnapshot{ResourceID: "/cache/orders", Kind: "entry", Format: "json"}, + apply: jvm.ApplyResult{Status: "applied"}, + }, nil + }) + defer restore() + + cfg := connection.ConnectionConfig{ + Type: "jvm", + ID: "conn-orders", + Host: "orders.internal", + JVM: connection.JVMConfig{ReadOnly: &readOnly, PreferredMode: "jmx", AllowedModes: []string{"jmx"}}, + } + for i := 0; i < 3; i++ { + res := app.JVMApplyChange(cfg, jvm.ChangeRequest{ + ProviderMode: "jmx", + ResourceID: "/cache/orders", + Action: "put", + Reason: fmt.Sprintf("repair cache %d", i), + Payload: map[string]any{"status": "ready"}, + }) + if !res.Success { + t.Fatalf("apply %d expected success, got %+v", i, res) + } + } + + listRes := app.JVMListAuditRecords("conn-orders", 20) + if !listRes.Success { + t.Fatalf("expected list success, got %+v", listRes) + } + records, ok := listRes.Data.([]jvm.AuditRecord) + if !ok { + t.Fatalf("expected records slice, got %#v", listRes.Data) + } + reasonLatestTs := map[string]int64{} + for _, record := range records { + if strings.HasPrefix(record.Reason, "repair cache ") { + reasonLatestTs[record.Reason+":"+record.Result] = record.Timestamp + } + } + for i := 0; i < 3; i++ { + reason := fmt.Sprintf("repair cache %d", i) + pendingTs := reasonLatestTs[reason+":pending"] + appliedTs := reasonLatestTs[reason+":applied"] + if pendingTs == 0 || appliedTs == 0 { + t.Fatalf("expected pending+applied for %s, got %#v", reason, records) + } + if appliedTs <= pendingTs { + t.Fatalf("expected applied ts > pending ts for %s, pending=%d applied=%d", reason, pendingTs, appliedTs) + } } } diff --git a/internal/jvm/guard.go b/internal/jvm/guard.go index bde86c9..fc86a16 100644 --- a/internal/jvm/guard.go +++ b/internal/jvm/guard.go @@ -2,6 +2,9 @@ package jvm import ( "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" "fmt" "strings" @@ -76,7 +79,17 @@ func BuildChangePreview( } } - if !preview.Allowed || provider == nil { + if !preview.Allowed { + return preview, nil + } + if provider == nil { + if preview.RequiresConfirmation { + confirmationToken, tokenErr := buildChangeConfirmationToken(normalized, req, preview) + if tokenErr != nil { + return ChangePreview{}, tokenErr + } + preview.ConfirmationToken = confirmationToken + } return preview, nil } @@ -106,6 +119,16 @@ func BuildChangePreview( if hasSnapshotOverride(providerPreview.After) { preview.After = mergeValueSnapshot(preview.After, providerPreview.After) } + if strings.EqualFold(strings.TrimSpace(preview.RiskLevel), "high") { + preview.RequiresConfirmation = true + } + if preview.Allowed && preview.RequiresConfirmation { + confirmationToken, tokenErr := buildChangeConfirmationToken(normalized, req, preview) + if tokenErr != nil { + return ChangePreview{}, tokenErr + } + preview.ConfirmationToken = confirmationToken + } return preview, nil } @@ -118,6 +141,7 @@ func NormalizeChangeRequest(req ChangeRequest) (ChangeRequest, error) { normalized.Reason = strings.TrimSpace(normalized.Reason) normalized.Source = strings.TrimSpace(normalized.Source) normalized.ExpectedVersion = strings.TrimSpace(normalized.ExpectedVersion) + normalized.ConfirmationToken = strings.TrimSpace(normalized.ConfirmationToken) if normalized.ResourceID == "" { return ChangeRequest{}, fmt.Errorf("resource id is required") @@ -138,7 +162,8 @@ func hasSnapshotOverride(snapshot ValueSnapshot) bool { strings.TrimSpace(snapshot.Format) != "" || strings.TrimSpace(snapshot.Version) != "" || snapshot.Value != nil || - snapshot.Metadata != nil + snapshot.Metadata != nil || + snapshot.Sensitive } func mergeValueSnapshot(base ValueSnapshot, override ValueSnapshot) ValueSnapshot { @@ -161,5 +186,67 @@ func mergeValueSnapshot(base ValueSnapshot, override ValueSnapshot) ValueSnapsho if override.Metadata != nil { merged.Metadata = override.Metadata } + if override.Sensitive { + merged.Sensitive = true + } return merged } + +func ValidateChangeConfirmation(preview ChangePreview, req ChangeRequest) error { + if !preview.RequiresConfirmation { + return nil + } + + previewToken := strings.TrimSpace(preview.ConfirmationToken) + requestToken := strings.TrimSpace(req.ConfirmationToken) + if previewToken == "" { + return fmt.Errorf("预览确认令牌缺失,请重新预览后再提交") + } + if requestToken == "" { + return fmt.Errorf("缺少确认令牌,请先完成预览确认") + } + if previewToken != requestToken { + return fmt.Errorf("确认令牌不匹配,请重新预览并确认") + } + return nil +} + +type confirmationTokenInput struct { + ConnectionID string `json:"connectionId"` + ProviderMode string `json:"providerMode"` + ResourceID string `json:"resourceId"` + Action string `json:"action"` + Reason string `json:"reason"` + Source string `json:"source"` + ExpectedVersion string `json:"expectedVersion"` + Payload map[string]any `json:"payload"` + Summary string `json:"summary"` + RiskLevel string `json:"riskLevel"` + BeforeVersion string `json:"beforeVersion"` + AfterVersion string `json:"afterVersion"` +} + +func buildChangeConfirmationToken(cfg connection.ConnectionConfig, req ChangeRequest, preview ChangePreview) (string, error) { + input := confirmationTokenInput{ + ConnectionID: strings.TrimSpace(cfg.ID), + ProviderMode: strings.TrimSpace(cfg.JVM.PreferredMode), + ResourceID: strings.TrimSpace(req.ResourceID), + Action: strings.TrimSpace(req.Action), + Reason: strings.TrimSpace(req.Reason), + Source: strings.TrimSpace(req.Source), + ExpectedVersion: strings.TrimSpace(req.ExpectedVersion), + Payload: req.Payload, + Summary: strings.TrimSpace(preview.Summary), + RiskLevel: strings.TrimSpace(preview.RiskLevel), + BeforeVersion: strings.TrimSpace(preview.Before.Version), + AfterVersion: strings.TrimSpace(preview.After.Version), + } + + encoded, err := json.Marshal(input) + if err != nil { + return "", fmt.Errorf("生成 JVM 变更确认令牌失败: %w", err) + } + + sum := sha256.Sum256(encoded) + return hex.EncodeToString(sum[:]), nil +} diff --git a/internal/jvm/guard_test.go b/internal/jvm/guard_test.go index 9c8f0a1..73d6807 100644 --- a/internal/jvm/guard_test.go +++ b/internal/jvm/guard_test.go @@ -68,6 +68,9 @@ func TestPreviewChangeBlocksReadOnlyConnection(t *testing.T) { if preview.BlockingReason == "" || !strings.Contains(preview.BlockingReason, "只读") { t.Fatalf("expected readonly blocking reason, got %#v", preview) } + if strings.TrimSpace(preview.ConfirmationToken) != "" { + t.Fatalf("expected blocked preview to not include confirmation token, got %#v", preview) + } if preview.Before.ResourceID != "/cache/orders" { t.Fatalf("expected before snapshot resource id to be preserved, got %#v", preview.Before) } @@ -169,6 +172,93 @@ func TestPreviewChangeMarksProdWritesAsConfirmationRequired(t *testing.T) { } } +func TestPreviewChangeMarksHighRiskWritesAsConfirmationRequired(t *testing.T) { + readOnly := false + + preview, err := BuildChangePreview(context.Background(), fakeGuardProvider{ + preview: ChangePreview{ + Allowed: true, + Summary: "provider high risk preview", + RiskLevel: "high", + }, + }, connection.ConnectionConfig{ + Type: "jvm", + ID: "conn-writable", + Host: "orders.internal", + JVM: connection.JVMConfig{ + ReadOnly: &readOnly, + PreferredMode: ModeJMX, + AllowedModes: []string{ModeJMX}, + }, + }, ChangeRequest{ + ProviderMode: ModeJMX, + ResourceID: "/mbean/java.lang:type=Memory/operation/gc", + Action: "invoke", + Reason: "manual maintenance", + Payload: map[string]any{ + "args": []any{}, + }, + }) + if err != nil { + t.Fatalf("BuildChangePreview returned error: %v", err) + } + if !preview.RequiresConfirmation { + t.Fatalf("expected high risk preview to require confirmation, got %#v", preview) + } + if strings.TrimSpace(preview.ConfirmationToken) == "" { + t.Fatalf("expected high risk preview to include confirmation token, got %#v", preview) + } +} + +func TestPreviewChangeMergesProviderSensitiveFlag(t *testing.T) { + readOnly := false + + preview, err := BuildChangePreview(context.Background(), fakeGuardProvider{ + before: ValueSnapshot{ + ResourceID: "/cache/orders/password", + Kind: "attribute", + Format: "string", + Value: "old-secret", + }, + preview: ChangePreview{ + Allowed: true, + Summary: "provider preview", + RiskLevel: "high", + Before: ValueSnapshot{ + Value: "old-secret", + Sensitive: true, + }, + After: ValueSnapshot{ + Value: "new-secret", + Sensitive: true, + }, + }, + }, connection.ConnectionConfig{ + Type: "jvm", + ID: "conn-writable", + Host: "orders.internal", + JVM: connection.JVMConfig{ + ReadOnly: &readOnly, + PreferredMode: ModeJMX, + AllowedModes: []string{ModeJMX}, + }, + }, ChangeRequest{ + ProviderMode: ModeJMX, + ResourceID: "/cache/orders/password", + Action: "set", + Reason: "rotate secret", + Payload: map[string]any{ + "value": "new-secret", + }, + }) + if err != nil { + t.Fatalf("BuildChangePreview returned error: %v", err) + } + if !preview.Before.Sensitive || !preview.After.Sensitive { + t.Fatalf("expected merged preview snapshots to preserve sensitive flag, got %#v", preview) + } +} + func TestPreviewChangeMergesProviderSnapshotsWithoutDroppingDefaults(t *testing.T) { readOnly := false @@ -224,3 +314,202 @@ func TestPreviewChangeMergesProviderSnapshotsWithoutDroppingDefaults(t *testing. t.Fatalf("expected after snapshot defaults to be preserved, got %#v", preview.After) } } + +func TestBuildChangePreviewAddsConfirmationTokenWhenRequired(t *testing.T) { + readOnly := false + + preview, err := BuildChangePreview(context.Background(), fakeGuardProvider{ + preview: ChangePreview{ + Allowed: true, + Summary: "invoke resize", + RiskLevel: "high", + RequiresConfirmation: true, + }, + }, connection.ConnectionConfig{ + Type: "jvm", + ID: "conn-prod", + Host: "orders.internal", + JVM: connection.JVMConfig{ + ReadOnly: &readOnly, + Environment: EnvPROD, + PreferredMode: ModeJMX, + AllowedModes: []string{ModeJMX}, + }, + }, ChangeRequest{ + ProviderMode: ModeJMX, + ResourceID: "/mbean/java.lang:type=Memory/operation/gc", + Action: "invoke", + Reason: "manual maintenance", + Payload: map[string]any{ + "args": []any{}, + }, + }) + if err != nil { + t.Fatalf("BuildChangePreview returned error: %v", err) + } + if !preview.RequiresConfirmation { + t.Fatalf("expected confirmation requirement, got %#v", preview) + } + if strings.TrimSpace(preview.ConfirmationToken) == "" { + t.Fatalf("expected confirmation token, got %#v", preview) + } +} + +func TestBuildChangePreviewUsesNormalizedProviderModeForConfirmationToken(t *testing.T) { + readOnly := false + cfg := connection.ConnectionConfig{ + Type: "jvm", + ID: "conn-prod", + Host: "orders.internal", + JVM: connection.JVMConfig{ + ReadOnly: &readOnly, + Environment: EnvPROD, + PreferredMode: ModeJMX, + AllowedModes: []string{ModeJMX}, + }, + } + + previewWithoutRequestedMode, err := BuildChangePreview(context.Background(), fakeGuardProvider{ + preview: ChangePreview{ + Allowed: true, + Summary: "invoke resize", + RiskLevel: "high", + RequiresConfirmation: true, + }, + }, cfg, ChangeRequest{ + ProviderMode: "", + ResourceID: "/mbean/java.lang:type=Memory/operation/gc", + Action: "invoke", + Reason: "manual maintenance", + Payload: map[string]any{ + "args": []any{}, + }, + }) + if err != nil { + t.Fatalf("BuildChangePreview returned error for empty provider mode: %v", err) + } + if strings.TrimSpace(previewWithoutRequestedMode.ConfirmationToken) == "" { + t.Fatalf("expected confirmation token for empty requested provider mode, got %#v", previewWithoutRequestedMode) + } + + previewWithRequestedMode, err := BuildChangePreview(context.Background(), fakeGuardProvider{ + preview: ChangePreview{ + Allowed: true, + Summary: "invoke resize", + RiskLevel: "high", + RequiresConfirmation: true, + }, + }, cfg, ChangeRequest{ + ProviderMode: ModeJMX, + ResourceID: "/mbean/java.lang:type=Memory/operation/gc", + Action: "invoke", + Reason: "manual maintenance", + Payload: map[string]any{ + "args": []any{}, + }, + }) + if err != nil { + t.Fatalf("BuildChangePreview returned error for explicit provider mode: %v", err) + } + if strings.TrimSpace(previewWithRequestedMode.ConfirmationToken) == "" { + t.Fatalf("expected confirmation token for explicit requested provider mode, got %#v", previewWithRequestedMode) + } + + if previewWithoutRequestedMode.ConfirmationToken != previewWithRequestedMode.ConfirmationToken { + t.Fatalf("expected tokens to match when normalized mode is the same, got %q vs %q", previewWithoutRequestedMode.ConfirmationToken, previewWithRequestedMode.ConfirmationToken) + } +} + +func TestBuildChangePreviewBlockedByProviderDoesNotGenerateConfirmationToken(t *testing.T) { + readOnly := false + + preview, err := BuildChangePreview(context.Background(), fakeGuardProvider{ + preview: ChangePreview{ + Allowed: false, + RequiresConfirmation: true, + BlockingReason: "provider denied write", + Summary: "blocked by provider", + RiskLevel: "high", + }, + }, connection.ConnectionConfig{ + Type: "jvm", + ID: "conn-prod", + Host: "orders.internal", + JVM: connection.JVMConfig{ + ReadOnly: &readOnly, + Environment: EnvPROD, + PreferredMode: ModeJMX, + AllowedModes: []string{ModeJMX}, + }, + }, ChangeRequest{ + ProviderMode: ModeJMX, + ResourceID: "/mbean/java.lang:type=Memory/operation/gc", + Action: "invoke", + Reason: "manual maintenance", + Payload: map[string]any{ + "args": []any{}, + }, + }) + if err != nil { + t.Fatalf("BuildChangePreview returned error: %v", err) + } + if preview.Allowed { + t.Fatalf("expected provider-blocked preview, got %#v", preview) + } + if strings.TrimSpace(preview.ConfirmationToken) != "" { + t.Fatalf("expected blocked preview to not include confirmation token, got %#v", preview) + } +} + +func TestBuildChangePreviewFailsClosedWhenTokenMarshalFails(t *testing.T) { + readOnly := false + _, err := BuildChangePreview(context.Background(), fakeGuardProvider{ + preview: ChangePreview{ + Allowed: true, + Summary: "invoke resize", + RiskLevel: "high", + RequiresConfirmation: true, + }, + }, connection.ConnectionConfig{ + Type: "jvm", + ID: "conn-prod", + Host: "orders.internal", + JVM: connection.JVMConfig{ + ReadOnly: &readOnly, + Environment: EnvPROD, + PreferredMode: ModeJMX, + AllowedModes: []string{ModeJMX}, + }, + }, ChangeRequest{ + ProviderMode: ModeJMX, + ResourceID: "/mbean/java.lang:type=Memory/operation/gc", + Action: "invoke", + Reason: "manual maintenance", + Payload: map[string]any{ + "invalid": func() {}, + }, + }) + if err == nil { + t.Fatal("expected BuildChangePreview to fail when confirmation token marshal fails") + } + if !strings.Contains(err.Error(), "确认令牌") { + t.Fatalf("expected error to mention confirmation token, got %v", err) + } +} + +func TestValidateChangeConfirmationRejectsMissingOrMismatchedToken(t *testing.T) { + preview := ChangePreview{ + Allowed: true, + RequiresConfirmation: true, + ConfirmationToken: "token-a", + } + if err := ValidateChangeConfirmation(preview, ChangeRequest{}); err == nil { + t.Fatal("expected missing confirmation token to be rejected") + } + if err := ValidateChangeConfirmation(preview, ChangeRequest{ConfirmationToken: "token-b"}); err == nil { + t.Fatal("expected mismatched confirmation token to be rejected") + } + if err := ValidateChangeConfirmation(preview, ChangeRequest{ConfirmationToken: "token-a"}); err != nil { + t.Fatalf("expected matching confirmation token to pass, got %v", err) + } +} diff --git a/internal/jvm/types.go b/internal/jvm/types.go index 9f54f87..ecc7135 100644 --- a/internal/jvm/types.go +++ b/internal/jvm/types.go @@ -58,18 +58,20 @@ type ValueSnapshot struct { } type ChangeRequest struct { - ProviderMode string `json:"providerMode"` - ResourceID string `json:"resourceId"` - Action string `json:"action"` - Reason string `json:"reason"` - Source string `json:"source,omitempty"` - ExpectedVersion string `json:"expectedVersion,omitempty"` - Payload map[string]any `json:"payload,omitempty"` + ProviderMode string `json:"providerMode"` + ResourceID string `json:"resourceId"` + Action string `json:"action"` + Reason string `json:"reason"` + Source string `json:"source,omitempty"` + ExpectedVersion string `json:"expectedVersion,omitempty"` + ConfirmationToken string `json:"confirmationToken,omitempty"` + Payload map[string]any `json:"payload,omitempty"` } type ChangePreview struct { Allowed bool `json:"allowed"` RequiresConfirmation bool `json:"requiresConfirmation,omitempty"` + ConfirmationToken string `json:"confirmationToken,omitempty"` Summary string `json:"summary"` RiskLevel string `json:"riskLevel"` BlockingReason string `json:"blockingReason,omitempty"` From 58ee269855c17a2373c1c2ffc355338b9b4051fa Mon Sep 17 00:00:00 2001 From: Syngnat Date: Tue, 28 Apr 2026 09:42:29 +0800 Subject: [PATCH 03/14] =?UTF-8?q?=F0=9F=90=9B=20fix(jvm):=20=E6=94=B6?= =?UTF-8?q?=E7=B4=A7=20JMX=20domain=20allowlist=20=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在 helper runtime 中对直接 ObjectName、资源浏览、变更预览和监控路径统一执行 domain allowlist,阻断默认域别名和空白后缀绕过。 --- internal/jvm/jmx_provider_test.go | 300 ++++++++++++++++++ .../jmxhelper_assets/jmx-helper-runtime.jar | Bin 26995 -> 27512 bytes .../src/com/gonavi/fixture/CacheSettings.java | 22 ++ .../gonavi/fixture/CacheSettingsMBean.java | 6 + .../src/com/gonavi/fixture/JMXTestServer.java | 8 + .../src/com/gonavi/jmxhelper/JmxRuntime.java | 75 ++++- 6 files changed, 401 insertions(+), 10 deletions(-) diff --git a/internal/jvm/jmx_provider_test.go b/internal/jvm/jmx_provider_test.go index 45ca682..3a183aa 100644 --- a/internal/jvm/jmx_provider_test.go +++ b/internal/jvm/jmx_provider_test.go @@ -287,6 +287,10 @@ func TestJMXProviderRealJMXRoundTrip(t *testing.T) { } provider := NewJMXProvider() + monitoringProvider, ok := provider.(MonitoringCapableProvider) + if !ok { + t.Fatal("expected JMX provider to implement monitoring snapshots") + } fixture := startJMXFixture(t) readOnly := false cfg := connection.ConnectionConfig{ @@ -335,14 +339,310 @@ func TestJMXProviderRealJMXRoundTrip(t *testing.T) { } mbean := mbeans[0] + blockedDomainPath := buildJMXResourcePath(jmxResourceTarget{ + Kind: jmxResourceKindDomain, + Domain: "java.lang", + }) + _, err = provider.ListResources(context.Background(), cfg, blockedDomainPath) + if err == nil { + t.Fatal("expected list on blocked domain to fail") + } + if got := err.Error(); !containsAll(got, "domain", "java.lang") { + t.Fatalf("expected blocked domain list context, got %v", err) + } + + _, err = provider.GetValue(context.Background(), cfg, blockedDomainPath) + if err == nil { + t.Fatal("expected get on blocked domain to fail") + } + if got := err.Error(); !containsAll(got, "domain", "java.lang") { + t.Fatalf("expected blocked domain get context, got %v", err) + } + + blockedMBeanPath := buildJMXResourcePath(jmxResourceTarget{ + Kind: jmxResourceKindMBean, + ObjectName: "java.lang:type=Memory", + }) + _, err = provider.ListResources(context.Background(), cfg, blockedMBeanPath) + if err == nil { + t.Fatal("expected list on blocked domain mbean to fail") + } + if got := err.Error(); !containsAll(got, "domain", "java.lang") { + t.Fatalf("expected blocked domain mbean list context, got %v", err) + } + + _, err = provider.GetValue(context.Background(), cfg, blockedMBeanPath) + if err == nil { + t.Fatal("expected direct mbean get on blocked domain to fail") + } + if got := err.Error(); !containsAll(got, "domain", "java.lang") { + t.Fatalf("expected blocked domain mbean get context, got %v", err) + } + + blockedAttributePath := buildJMXResourcePath(jmxResourceTarget{ + Kind: jmxResourceKindAttribute, + ObjectName: "java.lang:type=Memory", + Attribute: "HeapMemoryUsage", + }) + _, err = provider.GetValue(context.Background(), cfg, blockedAttributePath) + if err == nil { + t.Fatal("expected direct attribute get on blocked domain to fail") + } + if got := err.Error(); !containsAll(got, "domain", "java.lang") { + t.Fatalf("expected blocked domain attribute get context, got %v", err) + } + + blockedOperationPath := buildJMXResourcePath(jmxResourceTarget{ + Kind: jmxResourceKindOperation, + ObjectName: "java.lang:type=Memory", + Operation: "gc", + }) + _, err = provider.GetValue(context.Background(), cfg, blockedOperationPath) + if err == nil { + t.Fatal("expected direct operation get on blocked domain to fail") + } + if got := err.Error(); !containsAll(got, "domain", "java.lang") { + t.Fatalf("expected blocked domain operation get context, got %v", err) + } + + _, err = provider.PreviewChange(context.Background(), cfg, ChangeRequest{ + ProviderMode: ModeJMX, + ResourceID: blockedOperationPath, + Action: "invoke", + Reason: "尝试跨域操作预览", + }) + if err == nil { + t.Fatal("expected preview on blocked domain operation to fail") + } + if got := err.Error(); !containsAll(got, "domain", "java.lang") { + t.Fatalf("expected blocked domain operation preview context, got %v", err) + } + + _, err = provider.ApplyChange(context.Background(), cfg, ChangeRequest{ + ProviderMode: ModeJMX, + ResourceID: blockedOperationPath, + Action: "invoke", + Reason: "尝试跨域操作调用", + }) + if err == nil { + t.Fatal("expected apply on blocked domain operation to fail") + } + if got := err.Error(); !containsAll(got, "domain", "java.lang") { + t.Fatalf("expected blocked domain operation apply context, got %v", err) + } + + _, err = provider.PreviewChange(context.Background(), cfg, ChangeRequest{ + ProviderMode: ModeJMX, + ResourceID: blockedAttributePath, + Action: "update", + Reason: "尝试跨域属性预览", + Payload: map[string]any{ + "value": "blocked", + }, + }) + if err == nil { + t.Fatal("expected preview on blocked domain attribute to fail") + } + if got := err.Error(); !containsAll(got, "domain", "java.lang") { + t.Fatalf("expected blocked domain attribute preview context, got %v", err) + } + + _, err = provider.ApplyChange(context.Background(), cfg, ChangeRequest{ + ProviderMode: ModeJMX, + ResourceID: blockedAttributePath, + Action: "update", + Reason: "尝试跨域属性修改", + Payload: map[string]any{ + "value": "blocked", + }, + }) + if err == nil { + t.Fatal("expected apply on blocked domain attribute to fail") + } + if got := err.Error(); !containsAll(got, "domain", "java.lang") { + t.Fatalf("expected blocked domain attribute apply context, got %v", err) + } + + defaultDomainMBeanPath := buildJMXResourcePath(jmxResourceTarget{ + Kind: jmxResourceKindMBean, + ObjectName: ":type=CacheSettings,name=DefaultDomainCache", + }) + _, err = provider.ListResources(context.Background(), cfg, defaultDomainMBeanPath) + if err == nil { + t.Fatal("expected list on default domain alias mbean to fail") + } + if got := err.Error(); !containsAll(got, "domain") { + t.Fatalf("expected default domain alias mbean list context, got %v", err) + } + + defaultDomainAttributePath := buildJMXResourcePath(jmxResourceTarget{ + Kind: jmxResourceKindAttribute, + ObjectName: ":type=CacheSettings,name=DefaultDomainCache", + Attribute: "Mode", + }) + _, err = provider.GetValue(context.Background(), cfg, defaultDomainAttributePath) + if err == nil { + t.Fatal("expected get on default domain alias attribute to fail") + } + if got := err.Error(); !containsAll(got, "domain") { + t.Fatalf("expected default domain alias attribute get context, got %v", err) + } + + defaultDomainOperationPath := buildJMXResourcePath(jmxResourceTarget{ + Kind: jmxResourceKindOperation, + ObjectName: ":type=CacheSettings,name=DefaultDomainCache", + Operation: "resize", + Signature: []string{"int", "boolean"}, + }) + _, err = provider.PreviewChange(context.Background(), cfg, ChangeRequest{ + ProviderMode: ModeJMX, + ResourceID: defaultDomainOperationPath, + Action: "invoke", + Reason: "尝试默认域别名操作预览", + Payload: map[string]any{ + "args": []any{3, true}, + }, + }) + if err == nil { + t.Fatal("expected preview on default domain alias operation to fail") + } + if got := err.Error(); !containsAll(got, "domain") { + t.Fatalf("expected default domain alias operation preview context, got %v", err) + } + + _, err = provider.ApplyChange(context.Background(), cfg, ChangeRequest{ + ProviderMode: ModeJMX, + ResourceID: defaultDomainOperationPath, + Action: "invoke", + Reason: "尝试默认域别名操作调用", + Payload: map[string]any{ + "args": []any{3, true}, + }, + }) + if err == nil { + t.Fatal("expected apply on default domain alias operation to fail") + } + if got := err.Error(); !containsAll(got, "domain") { + t.Fatalf("expected default domain alias operation apply context, got %v", err) + } + + whitespaceDomainMBeanPath := buildJMXResourcePath(jmxResourceTarget{ + Kind: jmxResourceKindMBean, + ObjectName: "com.gonavi.fixture :type=CacheSettings,name=WhitespaceDomainCache", + }) + _, err = provider.ListResources(context.Background(), cfg, whitespaceDomainMBeanPath) + if err == nil { + t.Fatal("expected list on whitespace-suffixed domain mbean to fail") + } + if got := err.Error(); !containsAll(got, "domain", "com.gonavi.fixture") { + t.Fatalf("expected whitespace-suffixed domain mbean list context, got %v", err) + } + + whitespaceDomainAttributePath := buildJMXResourcePath(jmxResourceTarget{ + Kind: jmxResourceKindAttribute, + ObjectName: "com.gonavi.fixture :type=CacheSettings,name=WhitespaceDomainCache", + Attribute: "Mode", + }) + _, err = provider.GetValue(context.Background(), cfg, whitespaceDomainAttributePath) + if err == nil { + t.Fatal("expected get on whitespace-suffixed domain attribute to fail") + } + if got := err.Error(); !containsAll(got, "domain", "com.gonavi.fixture") { + t.Fatalf("expected whitespace-suffixed domain attribute get context, got %v", err) + } + + whitespaceDomainOperationPath := buildJMXResourcePath(jmxResourceTarget{ + Kind: jmxResourceKindOperation, + ObjectName: "com.gonavi.fixture :type=CacheSettings,name=WhitespaceDomainCache", + Operation: "resize", + Signature: []string{"int", "boolean"}, + }) + _, err = provider.PreviewChange(context.Background(), cfg, ChangeRequest{ + ProviderMode: ModeJMX, + ResourceID: whitespaceDomainOperationPath, + Action: "invoke", + Reason: "尝试空白后缀域操作预览", + Payload: map[string]any{ + "args": []any{4, true}, + }, + }) + if err == nil { + t.Fatal("expected preview on whitespace-suffixed domain operation to fail") + } + if got := err.Error(); !containsAll(got, "domain", "com.gonavi.fixture") { + t.Fatalf("expected whitespace-suffixed domain operation preview context, got %v", err) + } + + _, err = provider.ApplyChange(context.Background(), cfg, ChangeRequest{ + ProviderMode: ModeJMX, + ResourceID: whitespaceDomainOperationPath, + Action: "invoke", + Reason: "尝试空白后缀域操作调用", + Payload: map[string]any{ + "args": []any{4, true}, + }, + }) + if err == nil { + t.Fatal("expected apply on whitespace-suffixed domain operation to fail") + } + if got := err.Error(); !containsAll(got, "domain", "com.gonavi.fixture") { + t.Fatalf("expected whitespace-suffixed domain operation apply context, got %v", err) + } + + _, err = monitoringProvider.GetMonitoringSnapshot(context.Background(), cfg, nil) + if err == nil { + t.Fatal("expected monitor on blocked domain allowlist to fail") + } + if got := err.Error(); !containsAll(got, "domain", "java.lang") { + t.Fatalf("expected blocked domain monitor context, got %v", err) + } + + monitoringCfg := cfg + monitoringCfg.JVM.JMX.DomainAllowlist = []string{"com.gonavi.fixture", "java.lang"} + monitoringSnapshot, err := monitoringProvider.GetMonitoringSnapshot(context.Background(), monitoringCfg, nil) + if err != nil { + t.Fatalf("expected monitor with java.lang allowlist to succeed: %v", err) + } + if monitoringSnapshot.Point.Timestamp <= 0 { + t.Fatalf("unexpected monitor snapshot point: %#v", monitoringSnapshot.Point) + } + children, err := provider.ListResources(context.Background(), cfg, mbean.Path) if err != nil { t.Fatalf("ListResources(mbean) returned error: %v", err) } modeAttr := findResourceByName(t, children, "Mode") + passwordAttr := findResourceByName(t, children, "Password") + apiKeyAttr := findResourceByName(t, children, "ApiKey") lastInvocationAttr := findResourceByName(t, children, "LastInvocation") resizeOp := findResourceByName(t, children, "resize(int,boolean)") + passwordSnapshot, err := provider.GetValue(context.Background(), cfg, passwordAttr.Path) + if err != nil { + t.Fatalf("GetValue(password) returned error: %v", err) + } + if !passwordSnapshot.Sensitive { + t.Fatalf("expected password snapshot to be sensitive: %#v", passwordSnapshot) + } + for _, action := range passwordSnapshot.SupportedActions { + if payloadValue, ok := action.PayloadExample["value"]; ok && payloadValue == "secret-token" { + t.Fatalf("sensitive payload example leaked raw password: %#v", action.PayloadExample) + } + } + + apiKeySnapshot, err := provider.GetValue(context.Background(), cfg, apiKeyAttr.Path) + if err != nil { + t.Fatalf("GetValue(api key) returned error: %v", err) + } + if !apiKeySnapshot.Sensitive { + t.Fatalf("expected api key snapshot to be sensitive: %#v", apiKeySnapshot) + } + for _, action := range apiKeySnapshot.SupportedActions { + if payloadValue, ok := action.PayloadExample["value"]; ok && payloadValue == "api-key-secret" { + t.Fatalf("sensitive payload example leaked raw api key: %#v", action.PayloadExample) + } + } + modeBefore, err := provider.GetValue(context.Background(), cfg, modeAttr.Path) if err != nil { t.Fatalf("GetValue(mode before) returned error: %v", err) diff --git a/internal/jvm/jmxhelper_assets/jmx-helper-runtime.jar b/internal/jvm/jmxhelper_assets/jmx-helper-runtime.jar index 396bbc6a0c76a8e5ed5351d73adbf9f0f3be7b9e..6214d155f27b2dbcbcbcc6c58faf5186f60e3e8c 100644 GIT binary patch delta 20593 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 jQVHe6It8^Bwc9h7|?WBZkMixgTe|PIo;1c6scC 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 diff --git a/internal/jvm/testdata/jmxfixture/src/com/gonavi/fixture/CacheSettings.java b/internal/jvm/testdata/jmxfixture/src/com/gonavi/fixture/CacheSettings.java index b2393d6..c561c10 100644 --- a/internal/jvm/testdata/jmxfixture/src/com/gonavi/fixture/CacheSettings.java +++ b/internal/jvm/testdata/jmxfixture/src/com/gonavi/fixture/CacheSettings.java @@ -2,6 +2,8 @@ package com.gonavi.fixture; public final class CacheSettings implements CacheSettingsMBean { private volatile String mode = "warm"; + private volatile String password = "secret-token"; + private volatile String apiKey = "api-key-secret"; private final int hitCount = 7; private volatile String lastInvocation = "none"; @@ -15,6 +17,26 @@ public final class CacheSettings implements CacheSettingsMBean { this.mode = mode; } + @Override + public String getPassword() { + return password; + } + + @Override + public void setPassword(String password) { + this.password = password; + } + + @Override + public String getApiKey() { + return apiKey; + } + + @Override + public void setApiKey(String apiKey) { + this.apiKey = apiKey; + } + @Override public int getHitCount() { return hitCount; diff --git a/internal/jvm/testdata/jmxfixture/src/com/gonavi/fixture/CacheSettingsMBean.java b/internal/jvm/testdata/jmxfixture/src/com/gonavi/fixture/CacheSettingsMBean.java index 4673c8b..c954e44 100644 --- a/internal/jvm/testdata/jmxfixture/src/com/gonavi/fixture/CacheSettingsMBean.java +++ b/internal/jvm/testdata/jmxfixture/src/com/gonavi/fixture/CacheSettingsMBean.java @@ -4,6 +4,12 @@ public interface CacheSettingsMBean { String getMode(); void setMode(String mode); + String getPassword(); + void setPassword(String password); + + String getApiKey(); + void setApiKey(String apiKey); + int getHitCount(); String getLastInvocation(); diff --git a/internal/jvm/testdata/jmxfixture/src/com/gonavi/fixture/JMXTestServer.java b/internal/jvm/testdata/jmxfixture/src/com/gonavi/fixture/JMXTestServer.java index 2fb1bf3..b3f2929 100644 --- a/internal/jvm/testdata/jmxfixture/src/com/gonavi/fixture/JMXTestServer.java +++ b/internal/jvm/testdata/jmxfixture/src/com/gonavi/fixture/JMXTestServer.java @@ -15,6 +15,14 @@ public final class JMXTestServer { if (!server.isRegistered(objectName)) { server.registerMBean(new CacheSettings(), objectName); } + ObjectName defaultDomainObjectName = new ObjectName(":type=CacheSettings,name=DefaultDomainCache"); + if (!server.isRegistered(defaultDomainObjectName)) { + server.registerMBean(new CacheSettings(), defaultDomainObjectName); + } + ObjectName whitespaceDomainObjectName = new ObjectName("com.gonavi.fixture :type=CacheSettings,name=WhitespaceDomainCache"); + if (!server.isRegistered(whitespaceDomainObjectName)) { + server.registerMBean(new CacheSettings(), whitespaceDomainObjectName); + } System.out.println("READY"); System.out.flush(); diff --git a/tools/jmx-helper/src/com/gonavi/jmxhelper/JmxRuntime.java b/tools/jmx-helper/src/com/gonavi/jmxhelper/JmxRuntime.java index 46e1540..ba349d4 100644 --- a/tools/jmx-helper/src/com/gonavi/jmxhelper/JmxRuntime.java +++ b/tools/jmx-helper/src/com/gonavi/jmxhelper/JmxRuntime.java @@ -52,13 +52,13 @@ final class JmxRuntime { case "list": return listResources(server, connection, target); case "get": - return singleton("snapshot", getValue(server, target)); + return singleton("snapshot", getValue(server, connection, target)); case "monitor": - return singleton("monitoringSnapshot", getMonitoringSnapshot(server)); + return singleton("monitoringSnapshot", getMonitoringSnapshot(server, connection)); case "preview": - return singleton("preview", previewChange(server, target, change)); + return singleton("preview", previewChange(server, connection, target, change)); case "apply": - return singleton("applyResult", applyChange(server, target, change)); + return singleton("applyResult", applyChange(server, connection, target, change)); default: throw new IllegalArgumentException("unsupported helper command: " + command); } @@ -100,6 +100,7 @@ final class JmxRuntime { } if (target.isDomain()) { + requireDomainAllowed(connection, target.domain); Set names = server.queryNames(new ObjectName(target.domain + ":*"), null); List sortedNames = new ArrayList<>(names); Collections.sort(sortedNames, Comparator.comparing(ObjectName::getCanonicalName)); @@ -123,6 +124,7 @@ final class JmxRuntime { if (target.isMBean()) { ObjectName objectName = new ObjectName(target.objectName); + requireDomainAllowed(connection, objectName); MBeanInfo info = server.getMBeanInfo(objectName); MBeanAttributeInfo[] attributes = info.getAttributes(); @@ -170,10 +172,15 @@ final class JmxRuntime { throw new IllegalArgumentException("target kind " + target.kind + " does not support list"); } - private static Map getValue(MBeanServerConnection server, TargetSpec target) throws Exception { + private static Map getValue( + MBeanServerConnection server, + ConnectionSpec connection, + TargetSpec target + ) throws Exception { requireTarget(target); if (target.isDomain()) { + requireDomainAllowed(connection, target.domain); Set names = server.queryNames(new ObjectName(target.domain + ":*"), null); Map value = new LinkedHashMap<>(); value.put("domain", target.domain); @@ -182,6 +189,7 @@ final class JmxRuntime { } ObjectName objectName = new ObjectName(target.objectName); + requireDomainAllowed(connection, objectName); if (target.isMBean()) { MBeanInfo info = server.getMBeanInfo(objectName); List> attributes = new ArrayList<>(); @@ -219,7 +227,9 @@ final class JmxRuntime { throw new IllegalArgumentException("unsupported target kind: " + target.kind); } - private static Map getMonitoringSnapshot(MBeanServerConnection server) throws Exception { + private static Map getMonitoringSnapshot(MBeanServerConnection server, ConnectionSpec connection) throws Exception { + requireDomainAllowed(connection, "java.lang"); + LinkedHashMap result = new LinkedHashMap<>(); LinkedHashMap point = new LinkedHashMap<>(); List availableMetrics = new ArrayList<>(); @@ -423,6 +433,7 @@ final class JmxRuntime { private static Map previewChange( MBeanServerConnection server, + ConnectionSpec connection, TargetSpec target, Map change ) throws Exception { @@ -431,6 +442,7 @@ final class JmxRuntime { if (target.isAttribute()) { ObjectName objectName = new ObjectName(target.objectName); + requireDomainAllowed(connection, objectName); MBeanAttributeInfo attributeInfo = requireAttributeInfo(server, objectName, target.attribute); Map before = attributeSnapshot(objectName, attributeInfo, server.getAttribute(objectName, target.attribute)); if (!attributeInfo.isWritable()) { @@ -457,6 +469,7 @@ final class JmxRuntime { if (target.isOperation()) { ObjectName objectName = new ObjectName(target.objectName); + requireDomainAllowed(connection, objectName); MBeanOperationInfo operationInfo = requireOperationInfo(server, objectName, target.operation, target.signature); List args = argumentList(payload); String[] signature = effectiveSignature(target, payload, operationInfo); @@ -486,6 +499,7 @@ final class JmxRuntime { private static Map applyChange( MBeanServerConnection server, + ConnectionSpec connection, TargetSpec target, Map change ) throws Exception { @@ -494,6 +508,7 @@ final class JmxRuntime { if (target.isAttribute()) { ObjectName objectName = new ObjectName(target.objectName); + requireDomainAllowed(connection, objectName); MBeanAttributeInfo attributeInfo = requireAttributeInfo(server, objectName, target.attribute); if (!attributeInfo.isWritable()) { throw new IllegalArgumentException("attribute " + target.attribute + " is not writable"); @@ -512,6 +527,7 @@ final class JmxRuntime { if (target.isOperation()) { ObjectName objectName = new ObjectName(target.objectName); + requireDomainAllowed(connection, objectName); MBeanOperationInfo operationInfo = requireOperationInfo(server, objectName, target.operation, target.signature); List args = argumentList(payload); String[] signature = effectiveSignature(target, payload, operationInfo); @@ -590,17 +606,18 @@ final class JmxRuntime { Object value ) { Object jsonValue = toJsonCompatible(value); + boolean sensitive = isSensitiveName(attributeInfo.getName()); List> supportedActions = attributeInfo.isWritable() ? Collections.singletonList(actionDefinition( "set", "设置属性", "更新 JMX 属性 " + attributeInfo.getName(), - isSensitiveName(attributeInfo.getName()), + sensitive, Collections.singletonList(payloadField("value", attributeInfo.getType(), true, "目标属性值")), - metadata("value", jsonValue) + sensitive ? Collections.emptyMap() : metadata("value", jsonValue) )) : Collections.emptyList(); - return snapshot("attribute", inferFormat(jsonValue), jsonValue, attributeInfo.getDescription(), isSensitiveName(attributeInfo.getName()), supportedActions, metadata( + return snapshot("attribute", inferFormat(jsonValue), jsonValue, attributeInfo.getDescription(), sensitive, supportedActions, metadata( "objectName", objectName.toString(), "attribute", attributeInfo.getName(), "type", attributeInfo.getType(), @@ -898,13 +915,47 @@ final class JmxRuntime { return lowered.contains("password") || lowered.contains("secret") || lowered.contains("token") - || lowered.contains("credential"); + || lowered.contains("credential") + || lowered.contains("apikey") + || lowered.contains("api_key") + || lowered.contains("accesskey") + || lowered.contains("access_key") + || lowered.contains("privatekey") + || lowered.contains("private_key") + || lowered.contains("secretkey") + || lowered.contains("secret_key") + || lowered.contains("authkey") + || lowered.contains("auth_key"); } private static String domainOf(ObjectName objectName) { return objectName.getDomain(); } + private static void requireDomainAllowed(ConnectionSpec connection, String domain) { + if (connection == null) { + return; + } + String rawDomain = domain == null ? "" : domain; + String normalizedDomain = rawDomain.trim(); + if (normalizedDomain.isEmpty()) { + if (connection.hasDomainAllowlist()) { + throw new IllegalArgumentException("domain is not allowed: "); + } + return; + } + if (!rawDomain.equals(normalizedDomain) || !connection.isDomainAllowed(rawDomain)) { + throw new IllegalArgumentException("domain is not allowed: " + normalizedDomain); + } + } + + private static void requireDomainAllowed(ConnectionSpec connection, ObjectName objectName) { + if (objectName == null) { + return; + } + requireDomainAllowed(connection, objectName.getDomain()); + } + private static void requireTarget(TargetSpec target) { if (target == null || target.isRoot()) { throw new IllegalArgumentException("change target is required"); @@ -1263,6 +1314,10 @@ final class JmxRuntime { return new ConnectionSpec(host, port, username, password, allowlist); } + private boolean hasDomainAllowlist() { + return !domainAllowlist.isEmpty(); + } + private boolean isDomainAllowed(String domain) { return domainAllowlist.isEmpty() || domainAllowlist.contains(domain); } From ec2eefc9d2b2fda834fd57bfecdaf1769685b11f Mon Sep 17 00:00:00 2001 From: Syngnat Date: Tue, 28 Apr 2026 09:42:41 +0800 Subject: [PATCH 04/14] =?UTF-8?q?=F0=9F=90=9B=20fix(jvm):=20=E5=8A=A0?= =?UTF-8?q?=E5=9B=BA=E8=AF=8A=E6=96=AD=E5=91=BD=E4=BB=A4=E7=AD=96=E7=95=A5?= =?UTF-8?q?=E4=B8=8E=E8=BE=93=E5=87=BA=E8=84=B1=E6=95=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在服务端阻断只读连接中的高风险和多行诊断命令,并对诊断事件与错误消息统一脱敏,避免凭证、Authorization 和 PEM 片段泄漏。 --- .../components/JVMDiagnosticConsole.test.tsx | 676 +++++++++++++++++- .../src/components/JVMDiagnosticConsole.tsx | 122 +++- .../components/jvm/JVMDiagnosticOutput.tsx | 6 +- .../utils/jvmDiagnosticPresentation.test.ts | 200 ++++++ .../src/utils/jvmDiagnosticPresentation.ts | 178 ++++- internal/app/methods_jvm_diagnostic.go | 26 +- internal/app/methods_jvm_diagnostic_test.go | 485 ++++++++++++- internal/jvm/diagnostic_config.go | 24 + internal/jvm/diagnostic_config_test.go | 29 + internal/jvm/diagnostic_redaction.go | 215 ++++++ internal/jvm/diagnostic_redaction_test.go | 106 +++ 11 files changed, 2005 insertions(+), 62 deletions(-) create mode 100644 internal/jvm/diagnostic_redaction.go create mode 100644 internal/jvm/diagnostic_redaction_test.go diff --git a/frontend/src/components/JVMDiagnosticConsole.test.tsx b/frontend/src/components/JVMDiagnosticConsole.test.tsx index 1bb82f9..2b3b952 100644 --- a/frontend/src/components/JVMDiagnosticConsole.test.tsx +++ b/frontend/src/components/JVMDiagnosticConsole.test.tsx @@ -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 = () => ; + return { + ClearOutlined: Icon, + HistoryOutlined: Icon, + PauseCircleOutlined: Icon, + PlayCircleOutlined: Icon, + ReloadOutlined: Icon, + RocketOutlined: Icon, + ToolOutlined: Icon, + }; +}); + +vi.mock("antd", () => { + const passthrough = ({ children, style }: any) =>
{children}
; + const Text = ({ children, style }: any) => {children}; + const Paragraph = ({ children, style }: any) =>

{children}

; + const Title = ({ children, style }: any) =>

{children}

; + const Empty = ({ description }: any) =>
{description}
; + Empty.PRESENTED_IMAGE_SIMPLE = "simple"; + const List = ({ dataSource = [], renderItem }: any) => ( +
{dataSource.map((item: any, index: number) => renderItem(item, index))}
+ ); + List.Item = ({ children, style }: any) =>
{children}
; + const Typography = { Text, Paragraph, Title }; + return { + Alert: ({ message: alertMessage, description, style }: any) => ( +
{alertMessage}{description}
+ ), + Button: ({ children, onClick, disabled, style }: any) => , + Card: ({ children, title, style }: any) =>
{title}{children}
, + Empty, + Input: ({ value, onChange, placeholder }: any) => , + List, + Space: passthrough, + Tag: ({ children, style }: any) => {children}, + 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( + , + ); + + 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( + , + ); + }); + + 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( + , + ); + }); + + 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( + , + ); + }); + + 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( + , + ); + }); + + 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( + , + ); + }); + + 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( + , + ); + }); + + 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( + , + ); + }); + + 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( + , + ); + }); + + 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( + , + ); + }); + + 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"); + }); }); diff --git a/frontend/src/components/JVMDiagnosticConsole.tsx b/frontend/src/components/JVMDiagnosticConsole.tsx index b059555..4094620 100644 --- a/frontend/src/components/JVMDiagnosticConsole.tsx +++ b/frontend/src/components/JVMDiagnosticConsole.tsx @@ -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, +): string => `${chunk.sessionId || "unknown-session"}::${chunk.commandId || "unknown-command"}`; + const JVMDiagnosticConsole: React.FC = ({ tab }) => { const connection = useStore((state) => state.connections.find((item) => item.id === tab.connectionId), @@ -224,6 +232,41 @@ const JVMDiagnosticConsole: React.FC = ({ tab }) => { const [error, setError] = useState(""); const activeCommandIdRef = useRef(""); const terminalCommandIdsRef = useRef>(new Set()); + const redactionStatesRef = useRef>({}); + + const redactDiagnosticContent = useCallback( + ( + content: string, + chunk: Pick, + ) => { + 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ tab }) => { : record, ), ); + } else { + messageText = redactDiagnosticContent(rawMessageText, { sessionId, commandId }); } finishActiveCommand(commandId); setError(messageText); @@ -576,7 +628,7 @@ const JVMDiagnosticConsole: React.FC = ({ tab }) => { } message.info("已发送取消请求"); } catch (err: any) { - setError(err?.message || "取消诊断命令失败"); + setError(redactJVMDiagnosticOutput(err?.message || "取消诊断命令失败")); } finally { setLoading(false); } diff --git a/frontend/src/components/jvm/JVMDiagnosticOutput.tsx b/frontend/src/components/jvm/JVMDiagnosticOutput.tsx index 925f0fa..514d2a3 100644 --- a/frontend/src/components/jvm/JVMDiagnosticOutput.tsx +++ b/frontend/src/components/jvm/JVMDiagnosticOutput.tsx @@ -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 = ({ ); } + const chunkTexts = formatJVMDiagnosticChunksForDisplay(chunks); + return (
= ({ fontFamily: "SFMono-Regular, Menlo, Monaco, Consolas, monospace", }} > - {formatJVMDiagnosticChunkText(chunk)} + {chunkTexts[index]}
{chunk.phase ? ( diff --git a/frontend/src/utils/jvmDiagnosticPresentation.test.ts b/frontend/src/utils/jvmDiagnosticPresentation.test.ts index 0d3e144..b669978 100644 --- a/frontend/src/utils/jvmDiagnosticPresentation.test.ts +++ b/frontend/src/utils/jvmDiagnosticPresentation.test.ts @@ -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"); diff --git a/frontend/src/utils/jvmDiagnosticPresentation.ts b/frontend/src/utils/jvmDiagnosticPresentation.ts index 9af1444..4670c76 100644 --- a/frontend/src/utils/jvmDiagnosticPresentation.ts +++ b/frontend/src/utils/jvmDiagnosticPresentation.ts @@ -103,6 +103,160 @@ const SOURCE_LABELS: Record = { "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(), + ), + ); +}; diff --git a/internal/app/methods_jvm_diagnostic.go b/internal/app/methods_jvm_diagnostic.go index 0268bd6..99b3a8f 100644 --- a/internal/app/methods_jvm_diagnostic.go +++ b/internal/app/methods_jvm_diagnostic.go @@ -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, }) diff --git a/internal/app/methods_jvm_diagnostic_test.go b/internal/app/methods_jvm_diagnostic_test.go index 4288dbb..f29bb88 100644 --- a/internal/app/methods_jvm_diagnostic_test.go +++ b/internal/app/methods_jvm_diagnostic_test.go @@ -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) } diff --git a/internal/jvm/diagnostic_config.go b/internal/jvm/diagnostic_config.go index 61caff7..4c6e16e 100644 --- a/internal/jvm/diagnostic_config.go +++ b/internal/jvm/diagnostic_config.go @@ -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] diff --git a/internal/jvm/diagnostic_config_test.go b/internal/jvm/diagnostic_config_test.go index 4c60adf..8c84d83 100644 --- a/internal/jvm/diagnostic_config_test.go +++ b/internal/jvm/diagnostic_config_test.go @@ -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", diff --git a/internal/jvm/diagnostic_redaction.go b/internal/jvm/diagnostic_redaction.go new file mode 100644 index 0000000..6f9ceb4 --- /dev/null +++ b/internal/jvm/diagnostic_redaction.go @@ -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 +} diff --git a/internal/jvm/diagnostic_redaction_test.go b/internal/jvm/diagnostic_redaction_test.go new file mode 100644 index 0000000..7bdf359 --- /dev/null +++ b/internal/jvm/diagnostic_redaction_test.go @@ -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) + } +} From fa4f2a938a0676936428662170f9867166bc81e1 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Tue, 28 Apr 2026 09:42:48 +0800 Subject: [PATCH 05/14] =?UTF-8?q?=F0=9F=90=9B=20fix(jvm):=20=E7=BB=91?= =?UTF-8?q?=E5=AE=9A=E5=89=8D=E7=AB=AF=E5=8F=98=E6=9B=B4=E6=89=A7=E8=A1=8C?= =?UTF-8?q?=E5=88=B0=E9=A2=84=E8=A7=88=E4=B8=8A=E4=B8=8B=E6=96=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将 JVM 资源变更执行绑定到最近一次成功预览和连接配置指纹,并遮蔽敏感快照、payload 示例和 AI 上下文中的敏感值。 --- frontend/package-lock.json | 41 ++ frontend/package.json | 2 + .../JVMResourceBrowser.interaction.test.tsx | 563 ++++++++++++++++++ .../src/components/JVMResourceBrowser.tsx | 231 ++++--- .../components/jvm/JVMChangePreviewModal.tsx | 20 +- frontend/src/types.ts | 2 + frontend/src/utils/jvmAiPlan.test.ts | 36 +- frontend/src/utils/jvmAiPlan.ts | 4 + .../src/utils/jvmResourcePresentation.test.ts | 119 ++++ frontend/src/utils/jvmResourcePresentation.ts | 70 ++- 10 files changed, 1003 insertions(+), 85 deletions(-) create mode 100644 frontend/src/components/JVMResourceBrowser.interaction.test.tsx diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d6c584f..2b0ef39 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -33,8 +33,10 @@ "@types/react": "^18.2.43", "@types/react-dom": "^18.2.17", "@types/react-resizable": "^3.0.8", + "@types/react-test-renderer": "^18.0.7", "@types/uuid": "^9.0.7", "@vitejs/plugin-react": "^4.2.1", + "react-test-renderer": "^18.2.0", "typescript": "^5.2.2", "vite": "^5.0.8", "vitest": "^3.2.4" @@ -2037,6 +2039,16 @@ "@types/react": "*" } }, + "node_modules/@types/react-test-renderer": { + "version": "18.0.7", + "resolved": "https://registry.npmjs.org/@types/react-test-renderer/-/react-test-renderer-18.0.7.tgz", + "integrity": "sha512-1+ANPOWc6rB3IkSnElhjv6VLlKg2dSv/OWClUyZimbLsQyBn8Js9Vtdsi3UICJ2rIQ3k2la06dkB+C92QfhKmg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -5645,6 +5657,20 @@ "react-dom": ">= 16.3" } }, + "node_modules/react-shallow-renderer": { + "version": "16.15.0", + "resolved": "https://registry.npmjs.org/react-shallow-renderer/-/react-shallow-renderer-16.15.0.tgz", + "integrity": "sha512-oScf2FqQ9LFVQgA73vr86xl2NaOIX73rh+YFqcOp68CWj56tSfgtGKrEbyhCj0rSijyG9M1CYprTh39fBi5hzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "object-assign": "^4.1.1", + "react-is": "^16.12.0 || ^17.0.0 || ^18.0.0" + }, + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/react-syntax-highlighter": { "version": "16.1.1", "resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-16.1.1.tgz", @@ -5665,6 +5691,21 @@ "react": ">= 0.14.0" } }, + "node_modules/react-test-renderer": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react-test-renderer/-/react-test-renderer-18.2.0.tgz", + "integrity": "sha512-JWD+aQ0lh2gvh4NM3bBM42Kx+XybOxCpgYK7F8ugAlpaTSnWsX+39Z4XkOykGZAHrjwwTZT3x3KxswVWxHPUqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "react-is": "^18.2.0", + "react-shallow-renderer": "^16.15.0", + "scheduler": "^0.23.0" + }, + "peerDependencies": { + "react": "^18.2.0" + } + }, "node_modules/recharts": { "version": "3.8.1", "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index daddbfb..4181217 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -35,8 +35,10 @@ "@types/react": "^18.2.43", "@types/react-dom": "^18.2.17", "@types/react-resizable": "^3.0.8", + "@types/react-test-renderer": "^18.0.7", "@types/uuid": "^9.0.7", "@vitejs/plugin-react": "^4.2.1", + "react-test-renderer": "^18.2.0", "typescript": "^5.2.2", "vite": "^5.0.8", "vitest": "^3.2.4" diff --git a/frontend/src/components/JVMResourceBrowser.interaction.test.tsx b/frontend/src/components/JVMResourceBrowser.interaction.test.tsx new file mode 100644 index 0000000..b4f831d --- /dev/null +++ b/frontend/src/components/JVMResourceBrowser.interaction.test.tsx @@ -0,0 +1,563 @@ +import React from "react"; +import { act, create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import JVMResourceBrowser from "./JVMResourceBrowser"; +import type { JVMValueSnapshot } from "../types"; + +const storeState = vi.hoisted(() => ({ + connections: [ + { + id: "conn-jvm-writable", + name: "orders-jvm", + config: { + host: "127.0.0.1", + user: "jmx-user", + port: 9010, + type: "jvm", + jvm: { + preferredMode: "jmx", + readOnly: false, + jmx: { + password: "initial-jmx-secret", + }, + }, + }, + }, + ], + addTab: vi.fn(), + aiPanelVisible: false, + setAIPanelVisible: vi.fn(), + theme: "light", +})); + +const backendApp = vi.hoisted(() => ({ + JVMGetValue: vi.fn(), + JVMPreviewChange: vi.fn(), + JVMApplyChange: vi.fn(), +})); + +vi.mock("@monaco-editor/react", () => ({ + default: ({ value }: { value?: string }) =>
{value}
, +})); + +vi.mock("@ant-design/icons", () => ({ + FileSearchOutlined: () => , + ReloadOutlined: () => , + RobotOutlined: () => , +})); + +vi.mock("antd", () => { + const Text = ({ children }: any) => {children}; + const Button = ({ children, disabled, loading, onClick, type, ...rest }: any) => ( + + ); + const Card = ({ children, title }: any) => ( +
+

{title}

+ {children} +
+ ); + const Descriptions: any = ({ children }: any) =>
{children}
; + Descriptions.Item = ({ children, label }: any) => ( +
+
{label}
+
{children}
+
+ ); + const Input: any = ({ value, onChange, placeholder }: any) => ( + + ); + Input.TextArea = ({ value, onChange }: any) => ( +