Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions packages/api/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -969,9 +969,15 @@ type PAMPolicyRuleConfig struct {
Patterns []string `json:"patterns"`
}

// An older backend omits builtInDetection, which decodes as false.
type PAMSessionLogMaskingConfig struct {
Patterns []string `json:"patterns"`
BuiltInDetection bool `json:"builtInDetection"`
}

type PAMPolicyRules struct {
CommandBlocking *PAMPolicyRuleConfig `json:"command-blocking,omitempty"`
SessionLogMasking *PAMPolicyRuleConfig `json:"session-log-masking,omitempty"`
CommandBlocking *PAMPolicyRuleConfig `json:"command-blocking,omitempty"`
SessionLogMasking *PAMSessionLogMaskingConfig `json:"session-log-masking,omitempty"`
}

type PAMSessionCredentialsResponse struct {
Expand Down
5 changes: 5 additions & 0 deletions packages/gateway-v2/capabilities.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package gatewayv2

// Declared on each heartbeat. The platform stores the map verbatim, so an absent key means the
// gateway is too old to support the feature, not that the feature is off.
const CapabilitySessionLogMaskingBuiltInDetection = "sessionLogMaskingBuiltInDetection"
4 changes: 2 additions & 2 deletions packages/gateway-v2/enroll.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ import (
)

const (
INFISICAL_GATEWAY_ACCESS_TOKEN_KEY = "INFISICAL_GATEWAY_ACCESS_TOKEN"
INFISICAL_GATEWAY_DOMAIN_KEY = "INFISICAL_GATEWAY_DOMAIN"
INFISICAL_GATEWAY_ACCESS_TOKEN_KEY = "INFISICAL_GATEWAY_ACCESS_TOKEN"
INFISICAL_GATEWAY_DOMAIN_KEY = "INFISICAL_GATEWAY_DOMAIN"
INFISICAL_GATEWAY_ENROLLMENT_TOKEN_KEY = "INFISICAL_GATEWAY_ENROLLMENT_TOKEN"
)

Expand Down
5 changes: 4 additions & 1 deletion packages/gateway-v2/gateway.go
Original file line number Diff line number Diff line change
Expand Up @@ -455,7 +455,10 @@ func (g *Gateway) startMetricsReport(ctx context.Context) {

func (g *Gateway) registerHeartBeat(ctx context.Context, errCh chan error) {
sendHeartbeat := func() error {
capabilities := map[string]any{}
capabilities := map[string]any{
// Absence is how the platform spots a gateway too old to honour the setting.
CapabilitySessionLogMaskingBuiltInDetection: true,
}
if g.pkcs11Module != nil {
capabilities[CapabilityPkcs11] = true
}
Expand Down
20 changes: 4 additions & 16 deletions packages/pam/compile_patterns_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@ package pam

import (
"testing"

"github.com/Infisical/infisical-merge/packages/api"
)

func TestCompilePolicyPatterns(t *testing.T) {
Expand All @@ -15,38 +13,28 @@ func TestCompilePolicyPatterns(t *testing.T) {
})

t.Run("empty patterns returns nil", func(t *testing.T) {
config := &api.PAMPolicyRuleConfig{Patterns: []string{}}
result := compilePolicyPatterns(config, "sess-1", "test")
result := compilePolicyPatterns([]string{}, "sess-1", "test")
if result != nil {
t.Errorf("expected nil, got %v", result)
}
})

t.Run("valid patterns all compile", func(t *testing.T) {
config := &api.PAMPolicyRuleConfig{
Patterns: []string{`rm\s+-rf`, `shutdown`, `password\s*=\s*\S+`},
}
result := compilePolicyPatterns(config, "sess-1", "test")
result := compilePolicyPatterns([]string{`rm\s+-rf`, `shutdown`, `password\s*=\s*\S+`}, "sess-1", "test")
if len(result) != 3 {
t.Errorf("expected 3 compiled patterns, got %d", len(result))
}
})

t.Run("invalid pattern is skipped", func(t *testing.T) {
config := &api.PAMPolicyRuleConfig{
Patterns: []string{`rm\s+-rf`, `[invalid`, `shutdown`},
}
result := compilePolicyPatterns(config, "sess-1", "test")
result := compilePolicyPatterns([]string{`rm\s+-rf`, `[invalid`, `shutdown`}, "sess-1", "test")
if len(result) != 2 {
t.Errorf("expected 2 compiled patterns (1 skipped), got %d", len(result))
}
})

t.Run("all invalid patterns returns empty slice", func(t *testing.T) {
config := &api.PAMPolicyRuleConfig{
Patterns: []string{`[bad`, `(unclosed`},
}
result := compilePolicyPatterns(config, "sess-1", "test")
result := compilePolicyPatterns([]string{`[bad`, `(unclosed`}, "sess-1", "test")
if len(result) != 0 {
t.Errorf("expected 0 compiled patterns, got %d", len(result))
}
Expand Down
53 changes: 43 additions & 10 deletions packages/pam/pam-proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import (
"github.com/Infisical/infisical-merge/packages/pam/handlers/snowflake"
"github.com/Infisical/infisical-merge/packages/pam/handlers/ssh"
"github.com/Infisical/infisical-merge/packages/pam/session"
"github.com/Infisical/infisical-merge/packages/pam/session/masking"
"github.com/Infisical/infisical-merge/packages/util"
"github.com/go-resty/resty/v2"
"github.com/rs/zerolog/log"
Expand Down Expand Up @@ -152,13 +153,40 @@ func (c *activityConn) Write(b []byte) (int, error) {
return n, err
}

// Only the fields that are secret. Host, username, database and the like appear throughout normal
// output, so redacting them would gut the recording without protecting anything.
func credentialValues(c *session.PAMCredentials) []string {
if c == nil {
return nil
}
values := []string{
c.Password,
c.PrivateKey,
c.PrivateKeyPassphrase,
c.Token,
c.ServiceAccountToken,
c.ConnectionString,
}
for _, token := range c.Tokens {
values = append(values, token)
}
return values
}

func rulePatterns(rule *api.PAMPolicyRuleConfig) []string {
if rule == nil {
return nil
}
return rule.Patterns
}

// compilePolicyPatterns compiles regex pattern strings, logging warnings for any that fail.
func compilePolicyPatterns(config *api.PAMPolicyRuleConfig, sessionID string, ruleType string) []*regexp.Regexp {
if config == nil || len(config.Patterns) == 0 {
func compilePolicyPatterns(patterns []string, sessionID string, ruleType string) []*regexp.Regexp {
if len(patterns) == 0 {
return nil
}
var compiled []*regexp.Regexp
for _, pattern := range config.Patterns {
for _, pattern := range patterns {
re, err := regexp.Compile(pattern)
if err != nil {
log.Warn().
Expand Down Expand Up @@ -229,13 +257,18 @@ func HandlePAMProxy(ctx context.Context, conn *tls.Conn, pamConfig *GatewayPAMCo
return fmt.Errorf("failed to get PAM session encryption key: %w", err)
}

// Compile session log masking patterns from policy rules
var maskingPatterns []*regexp.Regexp
if credentials.PolicyRules != nil {
maskingPatterns = compilePolicyPatterns(credentials.PolicyRules.SessionLogMasking, pamConfig.SessionId, "session-log-masking")
masker := masking.Nop()
if credentials.PolicyRules != nil && credentials.PolicyRules.SessionLogMasking != nil {
rule := credentials.PolicyRules.SessionLogMasking
masker = masking.New(
compilePolicyPatterns(rule.Patterns, pamConfig.SessionId, "session-log-masking"),
rule.BuiltInDetection,
credentialValues(credentials),
pamConfig.SessionId,
)
}

sessionLogger, err := session.NewSessionLogger(pamConfig.SessionId, encryptionKey, pamConfig.ExpiryTime, pamConfig.ResourceType, maskingPatterns)
sessionLogger, err := session.NewSessionLogger(pamConfig.SessionId, encryptionKey, pamConfig.ExpiryTime, pamConfig.ResourceType, masker)
if err != nil {
return fmt.Errorf("failed to create session logger: %w", err)
}
Expand Down Expand Up @@ -371,7 +404,7 @@ func HandlePAMProxy(ctx context.Context, conn *tls.Conn, pamConfig *GatewayPAMCo
// Compile command blocking patterns from policy rules
var blockedCommandPatterns []*regexp.Regexp
if credentials.PolicyRules != nil {
blockedCommandPatterns = compilePolicyPatterns(credentials.PolicyRules.CommandBlocking, pamConfig.SessionId, "command-blocking")
blockedCommandPatterns = compilePolicyPatterns(rulePatterns(credentials.PolicyRules.CommandBlocking), pamConfig.SessionId, "command-blocking")
}

sshConfig := ssh.SSHProxyConfig{
Expand Down Expand Up @@ -522,7 +555,7 @@ func HandlePAMProxy(ctx context.Context, conn *tls.Conn, pamConfig *GatewayPAMCo
case session.ResourceTypeSnowflake:
var blockedCommands []*regexp.Regexp
if credentials.PolicyRules != nil {
blockedCommands = compilePolicyPatterns(credentials.PolicyRules.CommandBlocking, pamConfig.SessionId, "command-blocking")
blockedCommands = compilePolicyPatterns(rulePatterns(credentials.PolicyRules.CommandBlocking), pamConfig.SessionId, "command-blocking")
}

proxy := snowflake.NewSnowflakeProxy(snowflake.SnowflakeProxyConfig{
Expand Down
49 changes: 20 additions & 29 deletions packages/pam/session/logger.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,12 @@ import (
"net/http"
"os"
"path/filepath"
"regexp"
"sync"
"time"

"github.com/rs/zerolog/log"

"github.com/Infisical/infisical-merge/packages/pam/session/masking"
)

type sessionMutexInfo struct {
Expand Down Expand Up @@ -82,13 +83,13 @@ type SessionLogger interface {
}

type EncryptedSessionLogger struct {
sessionID string
encryptionKey string
expiresAt time.Time
file *os.File
mutex sync.Mutex
sessionStart time.Time // Track session start time for elapsed time calculation
maskingPatterns []*regexp.Regexp // Patterns for masking sensitive data in session logs
sessionID string
encryptionKey string
expiresAt time.Time
file *os.File
mutex sync.Mutex
sessionStart time.Time // Track session start time for elapsed time calculation
masker masking.Masker
}

type RequestResponsePair struct {
Expand Down Expand Up @@ -188,7 +189,7 @@ func CleanupSessionMutex(sessionID string) {
}
}

func NewSessionLogger(sessionID string, encryptionKey string, expiresAt time.Time, resourceType string, maskingPatterns []*regexp.Regexp) (*EncryptedSessionLogger, error) {
func NewSessionLogger(sessionID string, encryptionKey string, expiresAt time.Time, resourceType string, masker masking.Masker) (*EncryptedSessionLogger, error) {
if sessionID == "" {
return nil, fmt.Errorf("session ID cannot be empty")
}
Expand Down Expand Up @@ -219,12 +220,12 @@ func NewSessionLogger(sessionID string, encryptionKey string, expiresAt time.Tim
}

return &EncryptedSessionLogger{
sessionID: sessionID,
encryptionKey: encryptionKey,
expiresAt: expiresAt,
file: file,
sessionStart: time.Now(),
maskingPatterns: maskingPatterns,
sessionID: sessionID,
encryptionKey: encryptionKey,
expiresAt: expiresAt,
file: file,
sessionStart: time.Now(),
masker: masker,
}, nil
}

Expand Down Expand Up @@ -271,28 +272,18 @@ func (sl *EncryptedSessionLogger) writeEvent(productEventData func() ([]byte, er
return nil
}

// applyMasking replaces regex matches in byte data with [MASKED]
func (sl *EncryptedSessionLogger) applyMasking(data []byte) []byte {
if len(sl.maskingPatterns) == 0 || len(data) == 0 {
if sl.masker == nil {
return data
}
result := data
for _, pattern := range sl.maskingPatterns {
result = pattern.ReplaceAll(result, []byte("[MASKED]"))
}
return result
return sl.masker.Mask(data)
}

// applyMaskingString replaces regex matches in string data with [MASKED]
func (sl *EncryptedSessionLogger) applyMaskingString(s string) string {
if len(sl.maskingPatterns) == 0 || s == "" {
if sl.masker == nil {
return s
}
result := s
for _, pattern := range sl.maskingPatterns {
result = pattern.ReplaceAllString(result, "[MASKED]")
}
return result
return sl.masker.MaskString(s)
}

func (sl *EncryptedSessionLogger) LogEntry(entry SessionLogEntry) error {
Expand Down
6 changes: 4 additions & 2 deletions packages/pam/session/logger_masking_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,16 @@ package session
import (
"regexp"
"testing"

"github.com/Infisical/infisical-merge/packages/pam/session/masking"
)

func TestApplyMasking(t *testing.T) {
logger := &EncryptedSessionLogger{
maskingPatterns: []*regexp.Regexp{
masker: masking.New([]*regexp.Regexp{
regexp.MustCompile(`password\s*=\s*\S+`),
regexp.MustCompile(`secret_key`),
},
}, false, nil, "sess-1"),
}

tests := []struct {
Expand Down
61 changes: 61 additions & 0 deletions packages/pam/session/masking/credentials.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package masking

import "strings"

// A credential shorter than this would blank out ordinary words wherever they appear.
const minCredentialLength = 6

// credentialMasker redacts the account's own credential values.
type credentialMasker struct {
secrets []string
}

func (m *credentialMasker) MaskString(s string) string {
if s == "" {
return s
}
result := s
for _, secret := range m.secrets {
result = strings.ReplaceAll(result, secret, Placeholder)
}
return result
}

func (m *credentialMasker) Mask(data []byte) []byte {
if len(data) == 0 {
return data
}
original := string(data)
masked := m.MaskString(original)
if masked == original {
return data
}
return []byte(masked)
}

// newCredentialMasker returns nil when nothing is worth redacting, so callers can skip the stage.
func newCredentialMasker(values []string) *credentialMasker {
seen := make(map[string]struct{}, len(values))
var secrets []string
for _, v := range values {
if len([]rune(v)) < minCredentialLength {
continue
Comment thread
sheensantoscapadngan marked this conversation as resolved.
}
if _, dup := seen[v]; dup {
continue
}
seen[v] = struct{}{}
secrets = append(secrets, v)
}
if len(secrets) == 0 {
return nil
}
// Longest first: a credential containing another (a passphrase inside a key block) must be
// redacted before the shorter one rewrites the text around it.
for i := 1; i < len(secrets); i++ {
for j := i; j > 0 && len(secrets[j]) > len(secrets[j-1]); j-- {
secrets[j], secrets[j-1] = secrets[j-1], secrets[j]
}
}
return &credentialMasker{secrets: secrets}
}
Loading
Loading