mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-09 07:59:33 +08:00
🐛 fix(jvm): 加固诊断命令策略与输出脱敏
在服务端阻断只读连接中的高风险和多行诊断命令,并对诊断事件与错误消息统一脱敏,避免凭证、Authorization 和 PEM 片段泄漏。
This commit is contained in:
@@ -80,11 +80,35 @@ func ValidateDiagnosticCommandPolicy(cfg connection.JVMDiagnosticConfig, command
|
||||
return category, nil
|
||||
}
|
||||
|
||||
func ValidateDiagnosticExecutionPolicy(cfg connection.ConnectionConfig, command string) (string, error) {
|
||||
diagnosticCfg, err := NormalizeDiagnosticConfig(cfg)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
category, err := ValidateDiagnosticCommandPolicy(diagnosticCfg, command)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if cfg.JVM.ReadOnly != nil && *cfg.JVM.ReadOnly {
|
||||
switch category {
|
||||
case DiagnosticCommandCategoryTrace, DiagnosticCommandCategoryMutating:
|
||||
return "", fmt.Errorf("当前连接为只读模式,仅允许观察类诊断命令")
|
||||
}
|
||||
}
|
||||
|
||||
return category, nil
|
||||
}
|
||||
|
||||
func classifyDiagnosticCommand(command string) (string, string, error) {
|
||||
normalizedCommand := strings.TrimSpace(command)
|
||||
if normalizedCommand == "" {
|
||||
return "", "", fmt.Errorf("诊断命令不能为空")
|
||||
}
|
||||
if strings.ContainsAny(normalizedCommand, "\r\n") {
|
||||
return "", "", fmt.Errorf("诊断命令不支持换行或多命令输入")
|
||||
}
|
||||
|
||||
fields := strings.Fields(strings.ToLower(normalizedCommand))
|
||||
head := fields[0]
|
||||
|
||||
@@ -29,6 +29,35 @@ func TestNormalizeDiagnosticConfigDefaultsToDisabledObserveOnly(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDiagnosticCommandPolicyRejectsMultilineCommand(t *testing.T) {
|
||||
cfg, err := NormalizeDiagnosticConfig(connection.ConnectionConfig{
|
||||
Type: "jvm",
|
||||
Host: "orders.internal",
|
||||
JVM: connection.JVMConfig{
|
||||
Diagnostic: connection.JVMDiagnosticConfig{
|
||||
Enabled: true,
|
||||
Transport: DiagnosticTransportAgentBridge,
|
||||
BaseURL: "http://127.0.0.1:19091/gonavi/diag",
|
||||
AllowObserveCommands: true,
|
||||
AllowTraceCommands: true,
|
||||
AllowMutatingCommands: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NormalizeDiagnosticConfig returned error: %v", err)
|
||||
}
|
||||
|
||||
for _, command := range []string{
|
||||
"thread -n 1\nognl '@java.lang.System@setProperty(\"x\",\"y\")'",
|
||||
"thread -n 1\rwatch com.foo.OrderService submitOrder '{params}'",
|
||||
} {
|
||||
if _, err := ValidateDiagnosticCommandPolicy(cfg, command); err == nil {
|
||||
t.Fatalf("expected multiline command to be rejected: %q", command)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyDiagnosticCommandRejectsMutatingCommandWhenDisabled(t *testing.T) {
|
||||
cfg, err := NormalizeDiagnosticConfig(connection.ConnectionConfig{
|
||||
Type: "jvm",
|
||||
|
||||
215
internal/jvm/diagnostic_redaction.go
Normal file
215
internal/jvm/diagnostic_redaction.go
Normal file
@@ -0,0 +1,215 @@
|
||||
package jvm
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
const diagnosticRedactionMask = "********"
|
||||
|
||||
const diagnosticSensitiveKeyPattern = `(?:password|passwd|pwd|secret|token|credential|authorization|api[_.\- \t]*key|access[_.\- \t]*key|private[_.\- \t]*key|secret[_.\- \t]*key|auth[_.\- \t]*key|access[_.\- \t]*token|refresh[_.\- \t]*token)`
|
||||
const diagnosticSensitiveKeyBody = `[A-Za-z0-9_.\- \t]*` + diagnosticSensitiveKeyPattern + `[A-Za-z0-9_.\- \t]*`
|
||||
|
||||
var (
|
||||
diagnosticPEMEndPattern = regexp.MustCompile(`(?i)-----END [^-]*(?:PRIVATE KEY|SECRET|TOKEN|CREDENTIAL)[^-]*-----`)
|
||||
diagnosticPEMBeginPrefixPattern = regexp.MustCompile(`(?is)-----BEGIN[\s\S]*$`)
|
||||
diagnosticPEMEndContinuationPattern = regexp.MustCompile(`(?is)^[\s\S]*?-----END [^-]*(?:PRIVATE KEY|SECRET|TOKEN|CREDENTIAL)[^-]*-----`)
|
||||
diagnosticCompletePEMPattern = regexp.MustCompile(`(?is)-----BEGIN [^-]*(?:PRIVATE KEY|SECRET|TOKEN|CREDENTIAL)[\s\S]*?-----END [^-]*(?:PRIVATE KEY|SECRET|TOKEN|CREDENTIAL)[^-]*-----`)
|
||||
diagnosticPartialPEMPattern = regexp.MustCompile(`(?is)-----BEGIN [^-]*(?:PRIVATE KEY|SECRET|TOKEN|CREDENTIAL)[\s\S]*$`)
|
||||
diagnosticSensitivePEMLabels = []string{
|
||||
"PRIVATE KEY",
|
||||
"RSA PRIVATE KEY",
|
||||
"DSA PRIVATE KEY",
|
||||
"EC PRIVATE KEY",
|
||||
"OPENSSH PRIVATE KEY",
|
||||
"ENCRYPTED PRIVATE KEY",
|
||||
"SECRET",
|
||||
"TOKEN",
|
||||
"CREDENTIAL",
|
||||
}
|
||||
diagnosticDoubleQuotedValuePattern = regexp.MustCompile(`(?i)(")(` + diagnosticSensitiveKeyBody + `)(")([ \t]*:[ \t]*)(")((?:\\.|[^"\\])*)(")`)
|
||||
diagnosticSingleQuotedValuePattern = regexp.MustCompile(`(?i)(')(` + diagnosticSensitiveKeyBody + `)(')([ \t]*:[ \t]*)(')((?:\\.|[^'\\])*)(')`)
|
||||
diagnosticDoubleQuotedScalarPattern = regexp.MustCompile(`(?i)(")(` + diagnosticSensitiveKeyBody + `)(")([ \t]*:[ \t]*)(true|false|null|-?\d+(?:\.\d+)?)`)
|
||||
diagnosticSingleQuotedScalarPattern = regexp.MustCompile(`(?i)(')(` + diagnosticSensitiveKeyBody + `)(')([ \t]*:[ \t]*)(true|false|null|-?\d+(?:\.\d+)?)`)
|
||||
diagnosticUnquotedKeyValuePattern = regexp.MustCompile(`(?i)(^|[\r\n,;{\[?&]|\s)(` + diagnosticSensitiveKeyBody + `)([ \t]*[:=][ \t]*)([^\r\n&]*)`)
|
||||
diagnosticSensitivePEMBeginWithKeyPattern = regexp.MustCompile(`(?is)` + diagnosticSensitiveKeyBody + `[ \t]*[:=][ \t]*-----BEGIN[\s\S]*$`)
|
||||
diagnosticSensitiveKeyAssignmentTailPattern = regexp.MustCompile(`(?is)(^|[\r\n,;{\[?&]|\s)` + diagnosticSensitiveKeyBody + `[ \t]*[:=][ \t]*([^\r\n&]*)$`)
|
||||
)
|
||||
|
||||
type DiagnosticRedactionState struct {
|
||||
InsideSensitivePEM bool
|
||||
SawSensitivePEM bool
|
||||
PendingPEMBeginFragment string
|
||||
}
|
||||
|
||||
type DiagnosticOutputRedactor struct {
|
||||
mu sync.Mutex
|
||||
states map[string]*DiagnosticRedactionState
|
||||
}
|
||||
|
||||
func NewDiagnosticOutputRedactor() *DiagnosticOutputRedactor {
|
||||
return &DiagnosticOutputRedactor{states: map[string]*DiagnosticRedactionState{}}
|
||||
}
|
||||
|
||||
func (r *DiagnosticOutputRedactor) RedactChunk(chunk DiagnosticEventChunk) DiagnosticEventChunk {
|
||||
chunk.Content = r.RedactContent(chunk.SessionID, chunk.CommandID, chunk.Content)
|
||||
return chunk
|
||||
}
|
||||
|
||||
func (r *DiagnosticOutputRedactor) RedactContent(sessionID string, commandID string, content string) string {
|
||||
if r == nil {
|
||||
return RedactDiagnosticOutput(content)
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
key := diagnosticRedactionStateKey(sessionID, commandID)
|
||||
state := r.states[key]
|
||||
if state == nil {
|
||||
state = &DiagnosticRedactionState{}
|
||||
r.states[key] = state
|
||||
}
|
||||
return redactDiagnosticOutputWithState(content, state)
|
||||
}
|
||||
|
||||
func RedactDiagnosticOutput(content string) string {
|
||||
state := DiagnosticRedactionState{}
|
||||
return redactDiagnosticOutputWithState(content, &state)
|
||||
}
|
||||
|
||||
func diagnosticRedactionStateKey(sessionID string, commandID string) string {
|
||||
return strings.TrimSpace(sessionID) + "::" + strings.TrimSpace(commandID)
|
||||
}
|
||||
|
||||
func redactDiagnosticOutputWithState(content string, state *DiagnosticRedactionState) string {
|
||||
text := content
|
||||
if state.PendingPEMBeginFragment != "" {
|
||||
pending := state.PendingPEMBeginFragment
|
||||
state.PendingPEMBeginFragment = ""
|
||||
if isSensitivePEMBeginFragment(pending + content) {
|
||||
state.InsideSensitivePEM = true
|
||||
state.SawSensitivePEM = true
|
||||
}
|
||||
}
|
||||
if state.InsideSensitivePEM {
|
||||
pemEnd := diagnosticPEMEndPattern.FindStringIndex(text)
|
||||
if pemEnd == nil {
|
||||
return diagnosticRedactionMask
|
||||
}
|
||||
state.InsideSensitivePEM = false
|
||||
state.SawSensitivePEM = true
|
||||
text = diagnosticRedactionMask + diagnosticPEMEndPattern.ReplaceAllString(text[pemEnd[0]:], "")
|
||||
} else if state.SawSensitivePEM && diagnosticPEMEndPattern.MatchString(text) {
|
||||
text = diagnosticPEMEndContinuationPattern.ReplaceAllString(text, diagnosticRedactionMask)
|
||||
}
|
||||
|
||||
text = diagnosticCompletePEMPattern.ReplaceAllStringFunc(text, func(string) string {
|
||||
state.SawSensitivePEM = true
|
||||
return diagnosticRedactionMask
|
||||
})
|
||||
text = diagnosticPartialPEMPattern.ReplaceAllStringFunc(text, func(match string) string {
|
||||
state.SawSensitivePEM = true
|
||||
state.InsideSensitivePEM = !diagnosticPEMEndPattern.MatchString(match)
|
||||
return diagnosticRedactionMask
|
||||
})
|
||||
|
||||
if !state.InsideSensitivePEM && !diagnosticPEMEndPattern.MatchString(content) && hasSensitivePEMPartialBeginWithKey(content) {
|
||||
state.InsideSensitivePEM = true
|
||||
state.SawSensitivePEM = true
|
||||
}
|
||||
if !state.InsideSensitivePEM && hasSensitivePEMBeginPrefix(text) {
|
||||
state.InsideSensitivePEM = true
|
||||
state.SawSensitivePEM = true
|
||||
text = diagnosticPEMBeginPrefixPattern.ReplaceAllString(text, diagnosticRedactionMask)
|
||||
}
|
||||
if !state.InsideSensitivePEM && !diagnosticPEMEndPattern.MatchString(content) {
|
||||
if fragment := sensitivePEMBeginTailFragment(content); fragment != "" {
|
||||
state.PendingPEMBeginFragment = fragment
|
||||
state.SawSensitivePEM = true
|
||||
text = redactTrailingPEMBeginFragment(text, fragment)
|
||||
}
|
||||
}
|
||||
|
||||
return redactDiagnosticKeyValues(text)
|
||||
}
|
||||
|
||||
func hasSensitivePEMBeginPrefix(value string) bool {
|
||||
prefix := diagnosticPEMBeginPrefixPattern.FindString(value)
|
||||
if prefix == "" {
|
||||
return false
|
||||
}
|
||||
if isSensitivePEMBeginFragment(prefix) {
|
||||
return true
|
||||
}
|
||||
return diagnosticSensitivePEMBeginWithKeyPattern.MatchString(value)
|
||||
}
|
||||
|
||||
func hasSensitivePEMPartialBeginWithKey(value string) bool {
|
||||
matches := diagnosticSensitiveKeyAssignmentTailPattern.FindAllStringSubmatch(value, -1)
|
||||
for _, match := range matches {
|
||||
if len(match) >= 3 && isSensitivePEMBeginFragment(match[2]) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isSensitivePEMBeginFragment(value string) bool {
|
||||
fragment := strings.ToUpper(strings.TrimSpace(value))
|
||||
if fragment == "" {
|
||||
return false
|
||||
}
|
||||
marker := "-----BEGIN"
|
||||
if len(fragment) <= len(marker) {
|
||||
return strings.HasPrefix(marker, fragment) && strings.HasPrefix(fragment, "-")
|
||||
}
|
||||
if !strings.HasPrefix(fragment, marker) {
|
||||
return false
|
||||
}
|
||||
label := strings.TrimSpace(strings.TrimRight(strings.TrimPrefix(fragment, marker), "-"))
|
||||
label = strings.Join(strings.Fields(label), " ")
|
||||
if label == "" {
|
||||
return true
|
||||
}
|
||||
for _, item := range diagnosticSensitivePEMLabels {
|
||||
if strings.HasPrefix(item, label) || strings.HasPrefix(label, item) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func sensitivePEMBeginTailFragment(value string) string {
|
||||
line := value
|
||||
if idx := strings.LastIndexAny(line, "\r\n"); idx >= 0 {
|
||||
line = line[idx+1:]
|
||||
}
|
||||
for start := 0; start < len(line); start++ {
|
||||
fragment := line[start:]
|
||||
if isSensitivePEMBeginFragment(fragment) {
|
||||
return fragment
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func redactTrailingPEMBeginFragment(value string, fragment string) string {
|
||||
if fragment == "" {
|
||||
return value
|
||||
}
|
||||
idx := strings.LastIndex(value, fragment)
|
||||
if idx < 0 {
|
||||
return value
|
||||
}
|
||||
return value[:idx] + diagnosticRedactionMask
|
||||
}
|
||||
|
||||
func redactDiagnosticKeyValues(value string) string {
|
||||
text := diagnosticDoubleQuotedValuePattern.ReplaceAllString(value, `${1}${2}${3}${4}${5}`+diagnosticRedactionMask+`${7}`)
|
||||
text = diagnosticSingleQuotedValuePattern.ReplaceAllString(text, `${1}${2}${3}${4}${5}`+diagnosticRedactionMask+`${7}`)
|
||||
text = diagnosticDoubleQuotedScalarPattern.ReplaceAllString(text, `${1}${2}${3}${4}`+diagnosticRedactionMask)
|
||||
text = diagnosticSingleQuotedScalarPattern.ReplaceAllString(text, `${1}${2}${3}${4}`+diagnosticRedactionMask)
|
||||
text = diagnosticUnquotedKeyValuePattern.ReplaceAllString(text, `${1}${2}${3}`+diagnosticRedactionMask)
|
||||
return text
|
||||
}
|
||||
106
internal/jvm/diagnostic_redaction_test.go
Normal file
106
internal/jvm/diagnostic_redaction_test.go
Normal file
@@ -0,0 +1,106 @@
|
||||
package jvm
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDiagnosticOutputRedactorRedactsSensitiveKeyValues(t *testing.T) {
|
||||
redactor := NewDiagnosticOutputRedactor()
|
||||
|
||||
chunk := redactor.RedactChunk(DiagnosticEventChunk{
|
||||
SessionID: "sess-1",
|
||||
CommandID: "cmd-1",
|
||||
Content: strings.Join([]string{
|
||||
"password=secret-token",
|
||||
"api_key: api-secret",
|
||||
"Authorization: Bearer header-secret",
|
||||
`{"refresh_token":"json-secret"}`,
|
||||
"https://svc.local/callback?access_token=query-secret&x=1",
|
||||
}, "\n"),
|
||||
})
|
||||
|
||||
for _, leaked := range []string{"secret-token", "api-secret", "header-secret", "json-secret", "query-secret"} {
|
||||
if strings.Contains(chunk.Content, leaked) {
|
||||
t.Fatalf("redacted chunk leaked %q: %q", leaked, chunk.Content)
|
||||
}
|
||||
}
|
||||
for _, masked := range []string{"password=********", "api_key: ********", "Authorization: ********", `"refresh_token":"********"`, "access_token=********"} {
|
||||
if !strings.Contains(chunk.Content, masked) {
|
||||
t.Fatalf("expected redacted chunk to contain %q, got %q", masked, chunk.Content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiagnosticOutputRedactorRedactsPEMAcrossChunksAndRepeatedContinuation(t *testing.T) {
|
||||
redactor := NewDiagnosticOutputRedactor()
|
||||
|
||||
first := redactor.RedactChunk(DiagnosticEventChunk{
|
||||
SessionID: "sess-1",
|
||||
CommandID: "cmd-1",
|
||||
Content: "PRIVATE_KEY=-----BEGIN RSA PRIVATE K",
|
||||
})
|
||||
second := redactor.RedactChunk(DiagnosticEventChunk{
|
||||
SessionID: "sess-1",
|
||||
CommandID: "cmd-1",
|
||||
Content: "EY-----\nabc123\n-----END RSA PRIVATE KEY-----",
|
||||
})
|
||||
third := redactor.RedactContent("sess-1", "cmd-1", "abc123\n-----END RSA PRIVATE KEY-----")
|
||||
|
||||
combined := strings.Join([]string{first.Content, second.Content, third}, "\n")
|
||||
for _, leaked := range []string{"RSA PRIVATE K", "EY-----", "abc123"} {
|
||||
if strings.Contains(combined, leaked) {
|
||||
t.Fatalf("redacted PEM stream leaked %q: %q", leaked, combined)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiagnosticOutputRedactorRedactsPEMWhenBeginMarkerIsSplit(t *testing.T) {
|
||||
stream := "PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\nabc123\n-----END PRIVATE KEY-----"
|
||||
beginIndex := strings.Index(stream, "-----BEGIN")
|
||||
if beginIndex < 0 {
|
||||
t.Fatal("test stream missing PEM begin marker")
|
||||
}
|
||||
|
||||
for split := beginIndex + 1; split < beginIndex+len("-----BEGIN PRIVATE KEY"); split++ {
|
||||
redactor := NewDiagnosticOutputRedactor()
|
||||
combined := redactor.RedactContent("sess-1", "cmd-1", stream[:split]) + redactor.RedactContent("sess-1", "cmd-1", stream[split:])
|
||||
for _, leaked := range []string{"PRIVATE KEY", "abc123", "-----END"} {
|
||||
if strings.Contains(combined, leaked) {
|
||||
t.Fatalf("split at %d leaked %q: %q", split, leaked, combined)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiagnosticOutputRedactorRedactsRawPEMWhenBeginMarkerIsSplit(t *testing.T) {
|
||||
stream := "-----BEGIN PRIVATE KEY-----\nabc123\n-----END PRIVATE KEY-----"
|
||||
for split := 1; split < len("-----BEGIN PRIVATE KEY"); split++ {
|
||||
redactor := NewDiagnosticOutputRedactor()
|
||||
combined := redactor.RedactContent("sess-1", "cmd-1", stream[:split]) + redactor.RedactContent("sess-1", "cmd-1", stream[split:])
|
||||
for _, leaked := range []string{"-----BEG", "PRIVATE KEY", "abc123", "-----END"} {
|
||||
if strings.Contains(combined, leaked) {
|
||||
t.Fatalf("split at %d leaked %q: %q", split, leaked, combined)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiagnosticOutputRedactorDoesNotMaskUnrelatedCommandOutput(t *testing.T) {
|
||||
redactor := NewDiagnosticOutputRedactor()
|
||||
|
||||
_ = redactor.RedactChunk(DiagnosticEventChunk{
|
||||
SessionID: "sess-1",
|
||||
CommandID: "cmd-1",
|
||||
Content: "PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\nabc123",
|
||||
})
|
||||
other := redactor.RedactChunk(DiagnosticEventChunk{
|
||||
SessionID: "sess-1",
|
||||
CommandID: "cmd-2",
|
||||
Content: "thread_name=main",
|
||||
})
|
||||
|
||||
if other.Content != "thread_name=main" {
|
||||
t.Fatalf("expected unrelated command output unchanged, got %q", other.Content)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user