Skip to content

Commit e670230

Browse files
feat(pam): add command blocking and session log masking via account policies (#174)
Consume policyRules from the session credentials API to enforce two rule types: - command-blocking (SSH only): intercept Enter keypress, check accumulated command against regex patterns, suppress and show [BLOCKED] if matched. Also blocks matching exec requests. Session continues normally after block. - session-log-masking (all resource types): apply regex replacements on data fields before JSON marshaling in each Log method. Output events are buffered until newline so patterns can match across character-by-character echo. Masked content replaced with [MASKED]. Handles ANSI escape sequences (cursor position reports from web terminal) by tracking a 3-state parser in bufferInput so they don't pollute the command buffer. Co-authored-by: saif <11242541+saifsmailbox98@users.noreply.github.com>
1 parent 8149820 commit e670230

8 files changed

Lines changed: 570 additions & 52 deletions

File tree

packages/api/model.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -845,8 +845,18 @@ type PAMAccessApprovalRequestResponse struct {
845845
} `json:"request"`
846846
}
847847

848+
type PAMPolicyRuleConfig struct {
849+
Patterns []string `json:"patterns"`
850+
}
851+
852+
type PAMPolicyRules struct {
853+
CommandBlocking *PAMPolicyRuleConfig `json:"command-blocking,omitempty"`
854+
SessionLogMasking *PAMPolicyRuleConfig `json:"session-log-masking,omitempty"`
855+
}
856+
848857
type PAMSessionCredentialsResponse struct {
849858
Credentials PAMSessionCredentials `json:"credentials"`
859+
PolicyRules *PAMPolicyRules `json:"policyRules,omitempty"`
850860
}
851861

852862
type PAMSessionCredentials struct {
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
package pam
2+
3+
import (
4+
"testing"
5+
6+
"github.com/Infisical/infisical-merge/packages/api"
7+
)
8+
9+
func TestCompilePolicyPatterns(t *testing.T) {
10+
t.Run("nil config returns nil", func(t *testing.T) {
11+
result := compilePolicyPatterns(nil, "sess-1", "test")
12+
if result != nil {
13+
t.Errorf("expected nil, got %v", result)
14+
}
15+
})
16+
17+
t.Run("empty patterns returns nil", func(t *testing.T) {
18+
config := &api.PAMPolicyRuleConfig{Patterns: []string{}}
19+
result := compilePolicyPatterns(config, "sess-1", "test")
20+
if result != nil {
21+
t.Errorf("expected nil, got %v", result)
22+
}
23+
})
24+
25+
t.Run("valid patterns all compile", func(t *testing.T) {
26+
config := &api.PAMPolicyRuleConfig{
27+
Patterns: []string{`rm\s+-rf`, `shutdown`, `password\s*=\s*\S+`},
28+
}
29+
result := compilePolicyPatterns(config, "sess-1", "test")
30+
if len(result) != 3 {
31+
t.Errorf("expected 3 compiled patterns, got %d", len(result))
32+
}
33+
})
34+
35+
t.Run("invalid pattern is skipped", func(t *testing.T) {
36+
config := &api.PAMPolicyRuleConfig{
37+
Patterns: []string{`rm\s+-rf`, `[invalid`, `shutdown`},
38+
}
39+
result := compilePolicyPatterns(config, "sess-1", "test")
40+
if len(result) != 2 {
41+
t.Errorf("expected 2 compiled patterns (1 skipped), got %d", len(result))
42+
}
43+
})
44+
45+
t.Run("all invalid patterns returns empty slice", func(t *testing.T) {
46+
config := &api.PAMPolicyRuleConfig{
47+
Patterns: []string{`[bad`, `(unclosed`},
48+
}
49+
result := compilePolicyPatterns(config, "sess-1", "test")
50+
if len(result) != 0 {
51+
t.Errorf("expected 0 compiled patterns, got %d", len(result))
52+
}
53+
})
54+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
package ssh
2+
3+
import (
4+
"regexp"
5+
"testing"
6+
)
7+
8+
func TestMatchBlockedCommand(t *testing.T) {
9+
proxy := &SSHProxy{
10+
config: SSHProxyConfig{
11+
BlockedCommandPatterns: []*regexp.Regexp{
12+
regexp.MustCompile(`rm\s+-rf`),
13+
regexp.MustCompile(`shutdown`),
14+
regexp.MustCompile(`reboot`),
15+
},
16+
},
17+
}
18+
19+
tests := []struct {
20+
name string
21+
command string
22+
blocked bool
23+
}{
24+
{"blocks rm -rf", "rm -rf /", true},
25+
{"blocks rm -rf with extra space", "rm -rf /home", true},
26+
{"blocks sudo rm -rf", "sudo rm -rf /", true},
27+
{"blocks shutdown", "shutdown -h now", true},
28+
{"blocks reboot", "reboot", true},
29+
{"allows ls", "ls -la", false},
30+
{"allows rm without -rf", "rm file.txt", false},
31+
{"allows empty command", "", false},
32+
{"allows whitespace only", " ", false},
33+
{"allows normal commands", "cat /etc/hosts", false},
34+
}
35+
36+
for _, tt := range tests {
37+
t.Run(tt.name, func(t *testing.T) {
38+
result := proxy.matchBlockedCommand(tt.command)
39+
if result != tt.blocked {
40+
t.Errorf("matchBlockedCommand(%q) = %v, want %v", tt.command, result, tt.blocked)
41+
}
42+
})
43+
}
44+
}
45+
46+
func TestMatchBlockedCommandNoPatterns(t *testing.T) {
47+
proxy := &SSHProxy{
48+
config: SSHProxyConfig{},
49+
}
50+
51+
if proxy.matchBlockedCommand("rm -rf /") {
52+
t.Error("with no patterns, should never block")
53+
}
54+
}

0 commit comments

Comments
 (0)