8f0158eb9f
See CHANGELOG.md for the full breakdown. Summary:
- ldap_tunnel.go: serves a local unix socket for SSSD/PAM and relays raw
bytes to the SSO over the existing WSS channel (ldap_tunnel messages);
the agent never parses LDAP (DESIGN.md §4). Adds safeWriter to
serialize WebSocket writes now that telemetry, heartbeat, the LDAP
tunnel, and command responses all share one connection.
- secrets.go: renders local templates ({{ bao "path#key" }} placeholders)
by fetching node-scoped values from the SSO and writing the target
atomically at 0600, on a signed render_secrets command (DESIGN.md §5).
demo/ has minimal bash + Node consumers of the rendered file.
- iam.go: applies signed node IAM pushes -- sudoers.d rules (visudo -c
validated), SSH AuthorizedKeysCommand keys, /etc/security/access.conf,
and revocation via sss_cache -E + pkill -u (DESIGN.md §6).
- Capability reporting: the agent's enabled capabilities ride along in
its discovery frame so the SSO can show them in the Directory.
- DESIGN.md: the v2 protocol design this implements.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
370 lines
10 KiB
Go
370 lines
10 KiB
Go
package main
|
|
|
|
import (
|
|
"crypto/ed25519"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"os"
|
|
"testing"
|
|
)
|
|
|
|
// A fixed key pair for the tests, standing in for the SSO's persisted signing
|
|
// key. High-risk commands must now be genuinely signed: the agent fails closed
|
|
// when no public_key is configured, so these tests sign the way the server
|
|
// does instead of relying on verification being skipped.
|
|
var testPubKey, testPrivKey, _ = ed25519.GenerateKey(nil)
|
|
|
|
func testPubKeyB64() string {
|
|
return base64.StdEncoding.EncodeToString(testPubKey)
|
|
}
|
|
|
|
// sign mirrors the server's canonicalization (sorted keys, no whitespace, no
|
|
// HTML escaping, `signature` omitted) and adds the signature to the payload.
|
|
func sign(t *testing.T, payload map[string]interface{}) map[string]interface{} {
|
|
t.Helper()
|
|
if payload == nil {
|
|
payload = map[string]interface{}{}
|
|
}
|
|
canonical, err := canonicalize(payload)
|
|
if err != nil {
|
|
t.Fatalf("canonicalize: %v", err)
|
|
}
|
|
signed := make(map[string]interface{}, len(payload)+1)
|
|
for k, v := range payload {
|
|
signed[k] = v
|
|
}
|
|
signed["signature"] = base64.StdEncoding.EncodeToString(ed25519.Sign(testPrivKey, canonical))
|
|
return signed
|
|
}
|
|
|
|
type MockConn struct {
|
|
Messages [][]byte
|
|
}
|
|
|
|
func (m *MockConn) WriteMessage(messageType int, data []byte) error {
|
|
m.Messages = append(m.Messages, data)
|
|
return nil
|
|
}
|
|
|
|
type MockExecutor struct {
|
|
ExecutedCommands [][]string
|
|
WrittenFiles map[string][]byte
|
|
}
|
|
|
|
func (m *MockExecutor) Execute(command string, args ...string) ([]byte, error) {
|
|
m.ExecutedCommands = append(m.ExecutedCommands, append([]string{command}, args...))
|
|
return []byte("mock output"), nil
|
|
}
|
|
|
|
func (m *MockExecutor) WriteFile(path string, data []byte, perm os.FileMode) error {
|
|
if m.WrittenFiles == nil {
|
|
m.WrittenFiles = make(map[string][]byte)
|
|
}
|
|
m.WrittenFiles[path] = data
|
|
return nil
|
|
}
|
|
|
|
func (m *MockExecutor) ReadFile(path string) ([]byte, error) {
|
|
if m.WrittenFiles != nil {
|
|
if data, ok := m.WrittenFiles[path]; ok {
|
|
return data, nil
|
|
}
|
|
}
|
|
return []byte("mock file content"), nil
|
|
}
|
|
|
|
func TestHandleCommand(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
cfg *Config
|
|
msg WSMessage
|
|
expectedStatus string
|
|
expectedCmd []string
|
|
expectedFile string
|
|
expectedFileCont string
|
|
// sign the payload with the test key before dispatch, the way the SSO
|
|
// signs high-risk commands
|
|
signed bool
|
|
// heartbeat_ack (and any fire-and-forget ack) must be silently ignored —
|
|
// no response message, no command, no log noise.
|
|
expectedNoResponse bool
|
|
}{
|
|
{
|
|
name: "config command success",
|
|
cfg: &Config{
|
|
Capabilities: Capabilities{},
|
|
},
|
|
msg: WSMessage{
|
|
Type: "config",
|
|
Payload: map[string]interface{}{"key": "value"},
|
|
},
|
|
expectedStatus: "ok",
|
|
},
|
|
{
|
|
name: "reboot command allowed",
|
|
cfg: &Config{
|
|
PublicKey: testPubKeyB64(),
|
|
Capabilities: Capabilities{Reboot: true},
|
|
},
|
|
msg: WSMessage{
|
|
Type: "reboot",
|
|
},
|
|
signed: true,
|
|
expectedStatus: "ok",
|
|
expectedCmd: []string{"reboot"},
|
|
},
|
|
{
|
|
name: "reboot command denied",
|
|
cfg: &Config{
|
|
Capabilities: Capabilities{Reboot: false},
|
|
},
|
|
msg: WSMessage{
|
|
Type: "reboot",
|
|
},
|
|
expectedStatus: "error",
|
|
expectedCmd: nil,
|
|
},
|
|
{
|
|
name: "service_restart allowed",
|
|
cfg: &Config{
|
|
Capabilities: Capabilities{
|
|
ServiceControl: []string{"nginx"},
|
|
},
|
|
},
|
|
msg: WSMessage{
|
|
Type: "service_restart",
|
|
Payload: map[string]interface{}{
|
|
"service": "nginx",
|
|
},
|
|
},
|
|
expectedStatus: "ok",
|
|
expectedCmd: []string{"systemctl", "restart", "nginx"},
|
|
},
|
|
{
|
|
name: "service_restart denied",
|
|
cfg: &Config{
|
|
Capabilities: Capabilities{
|
|
ServiceControl: []string{"nginx"},
|
|
},
|
|
},
|
|
msg: WSMessage{
|
|
Type: "service_restart",
|
|
Payload: map[string]interface{}{
|
|
"service": "ssh",
|
|
},
|
|
},
|
|
expectedStatus: "error",
|
|
expectedCmd: nil,
|
|
},
|
|
{
|
|
name: "configure_ldap allowed",
|
|
cfg: &Config{
|
|
PublicKey: testPubKeyB64(),
|
|
Capabilities: Capabilities{ConfigureLDAP: true},
|
|
},
|
|
msg: WSMessage{
|
|
Type: "configure_ldap",
|
|
Payload: map[string]interface{}{
|
|
"config": "domain = theta42.local\nserver = sso.local",
|
|
},
|
|
},
|
|
signed: true,
|
|
expectedStatus: "ok",
|
|
expectedFile: "/etc/sssd/sssd.conf",
|
|
expectedFileCont: "domain = theta42.local\nserver = sso.local",
|
|
expectedCmd: []string{"systemctl", "restart", "sssd"},
|
|
},
|
|
{
|
|
name: "configure_ldap denied",
|
|
cfg: &Config{
|
|
Capabilities: Capabilities{ConfigureLDAP: false},
|
|
},
|
|
msg: WSMessage{
|
|
Type: "configure_ldap",
|
|
Payload: map[string]interface{}{
|
|
"config": "domain = theta42.local",
|
|
},
|
|
},
|
|
expectedStatus: "error",
|
|
expectedCmd: nil,
|
|
},
|
|
{
|
|
name: "arbitrary_bash allowed",
|
|
cfg: &Config{
|
|
PublicKey: testPubKeyB64(),
|
|
Capabilities: Capabilities{ArbitraryBash: true},
|
|
},
|
|
msg: WSMessage{
|
|
Type: "arbitrary_bash",
|
|
Payload: map[string]interface{}{
|
|
"script": "uptime",
|
|
},
|
|
},
|
|
signed: true,
|
|
expectedStatus: "ok",
|
|
expectedCmd: []string{"bash", "-c", "uptime"},
|
|
},
|
|
{
|
|
name: "arbitrary_bash denied",
|
|
cfg: &Config{
|
|
Capabilities: Capabilities{ArbitraryBash: false},
|
|
},
|
|
msg: WSMessage{
|
|
Type: "arbitrary_bash",
|
|
Payload: map[string]interface{}{
|
|
"script": "rm -rf /",
|
|
},
|
|
},
|
|
expectedStatus: "error",
|
|
expectedCmd: nil,
|
|
},
|
|
{
|
|
name: "heartbeat_ack is silently ignored",
|
|
cfg: &Config{
|
|
Capabilities: Capabilities{},
|
|
},
|
|
msg: WSMessage{
|
|
Type: "heartbeat_ack",
|
|
},
|
|
expectedNoResponse: true,
|
|
},
|
|
{
|
|
name: "unknown command",
|
|
cfg: &Config{
|
|
Capabilities: Capabilities{},
|
|
},
|
|
msg: WSMessage{
|
|
Type: "mystery_command",
|
|
},
|
|
expectedStatus: "error",
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
mockConn := &MockConn{}
|
|
mockExec := &MockExecutor{}
|
|
cm := &ConfigManager{current: tc.cfg}
|
|
msg := tc.msg
|
|
if tc.signed {
|
|
msg.Payload = sign(t, msg.Payload)
|
|
}
|
|
handleCommand(cm, msg, mockConn, mockExec, nil)
|
|
|
|
if tc.expectedNoResponse {
|
|
if len(mockConn.Messages) != 0 {
|
|
t.Fatalf("expected no response message, got %d: %v", len(mockConn.Messages), mockConn.Messages)
|
|
}
|
|
if len(mockExec.ExecutedCommands) > 0 {
|
|
t.Errorf("expected no commands to be executed, but got %v", mockExec.ExecutedCommands)
|
|
}
|
|
return
|
|
}
|
|
|
|
if len(mockConn.Messages) != 1 {
|
|
t.Fatalf("expected 1 response message, got %d", len(mockConn.Messages))
|
|
}
|
|
|
|
var resp map[string]string
|
|
if err := json.Unmarshal(mockConn.Messages[0], &resp); err != nil {
|
|
t.Fatalf("failed to unmarshal response: %v", err)
|
|
}
|
|
|
|
if resp["status"] != tc.expectedStatus {
|
|
t.Errorf("expected status %q, got %q", tc.expectedStatus, resp["status"])
|
|
}
|
|
|
|
if tc.expectedCmd != nil {
|
|
if len(mockExec.ExecutedCommands) == 0 {
|
|
t.Errorf("expected command to be executed, but none were")
|
|
} else {
|
|
cmd := mockExec.ExecutedCommands[0]
|
|
if len(cmd) != len(tc.expectedCmd) {
|
|
t.Errorf("expected command length %d, got %d", len(tc.expectedCmd), len(cmd))
|
|
}
|
|
for i := range cmd {
|
|
if cmd[i] != tc.expectedCmd[i] {
|
|
t.Errorf("expected arg %d = %q, got %q", i, tc.expectedCmd[i], cmd[i])
|
|
}
|
|
}
|
|
}
|
|
} else if len(mockExec.ExecutedCommands) > 0 {
|
|
t.Errorf("expected no commands to be executed, but got %v", mockExec.ExecutedCommands)
|
|
}
|
|
|
|
if tc.expectedFile != "" {
|
|
content, ok := mockExec.WrittenFiles[tc.expectedFile]
|
|
if !ok {
|
|
t.Errorf("expected file %q to be written, but it wasn't", tc.expectedFile)
|
|
} else if string(content) != tc.expectedFileCont {
|
|
t.Errorf("expected file content %q, got %q", tc.expectedFileCont, string(content))
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// The agent must not execute a high-risk command it cannot verify. This used to
|
|
// return true when no public_key was configured, so an agent installed without
|
|
// one executed reboot / configure_ldap / arbitrary_bash unverified.
|
|
func TestVerifySignatureFailsClosedWithoutPublicKey(t *testing.T) {
|
|
cfg := &Config{} // no PublicKey
|
|
msg := WSMessage{Type: "arbitrary_bash", Payload: sign(t, map[string]interface{}{"script": "uptime"})}
|
|
if verifySignature(cfg, msg) {
|
|
t.Fatal("verifySignature accepted a command with no public_key configured")
|
|
}
|
|
}
|
|
|
|
func TestVerifySignatureRejectsWrongKey(t *testing.T) {
|
|
otherPub, _, _ := ed25519.GenerateKey(nil)
|
|
cfg := &Config{PublicKey: base64.StdEncoding.EncodeToString(otherPub)}
|
|
msg := WSMessage{Type: "arbitrary_bash", Payload: sign(t, map[string]interface{}{"script": "uptime"})}
|
|
if verifySignature(cfg, msg) {
|
|
t.Fatal("verifySignature accepted a signature from a different key")
|
|
}
|
|
}
|
|
|
|
func TestVerifySignatureRejectsTamperedPayload(t *testing.T) {
|
|
cfg := &Config{PublicKey: testPubKeyB64()}
|
|
payload := sign(t, map[string]interface{}{"script": "uptime"})
|
|
payload["script"] = "rm -rf /" // swap the script, keep the signature
|
|
if verifySignature(cfg, WSMessage{Type: "arbitrary_bash", Payload: payload}) {
|
|
t.Fatal("verifySignature accepted a payload modified after signing")
|
|
}
|
|
}
|
|
|
|
// Regression: encoding/json escapes <, > and & by default, but the server's
|
|
// JSON.stringify does not. Any script using redirection or && therefore
|
|
// canonicalized differently on each side and failed verification -- which is
|
|
// most real scripts.
|
|
func TestVerifySignatureAcceptsShellMetacharacters(t *testing.T) {
|
|
cfg := &Config{PublicKey: testPubKeyB64()}
|
|
for _, script := range []string{
|
|
"echo hi > /tmp//out.log",
|
|
"systemctl is-active nginx && systemctl reload nginx",
|
|
"grep -c . < /etc/passwd",
|
|
"a=1 && b=2 && echo \"$a<$b\" > /dev/null",
|
|
} {
|
|
msg := WSMessage{Type: "arbitrary_bash", Payload: sign(t, map[string]interface{}{"script": script})}
|
|
if !verifySignature(cfg, msg) {
|
|
t.Errorf("verifySignature rejected a correctly signed script: %q", script)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The canonical form must be byte-identical to the server's: sorted keys, no
|
|
// whitespace, no HTML escaping, no trailing newline, signature omitted.
|
|
func TestCanonicalizeMatchesServerForm(t *testing.T) {
|
|
got, err := canonicalize(map[string]interface{}{
|
|
"script": "echo a > b && c",
|
|
"comment": "x&y",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("canonicalize: %v", err)
|
|
}
|
|
want := `{"comment":"x&y","script":"echo a > b && c"}`
|
|
if string(got) != want {
|
|
t.Errorf("canonical form mismatch:\n got: %s\nwant: %s", got, want)
|
|
}
|
|
}
|