From 809a010a1d31226bbba9fe953fd95e26ab9cebb7 Mon Sep 17 00:00:00 2001 From: Zachary Wasserman Date: Thu, 11 Aug 2016 13:50:03 -0700 Subject: [PATCH] Initial implementation of osqueryd enrollment + tests (#36) *EnrollHost now generates a node key and stores host information into the DB * Unit and integration tests Closes #6 --- app/osquery.go | 82 +++++++++++++++- app/osquery_integration_test.go | 143 ++++++++++++++++++++++++++++ app/osquery_test.go | 93 ++++++++++++++++++ app/test_util.go | 20 ++++ config/config.go | 26 +++-- errors/errors_test.go | 2 - tools/app/example_config.json | 3 +- tools/osquery/example_osquery.flags | 1 + 8 files changed, 356 insertions(+), 14 deletions(-) create mode 100644 app/osquery_integration_test.go create mode 100644 app/osquery_test.go diff --git a/app/osquery.go b/app/osquery.go index 615289bf3d..fbecc9ce8d 100644 --- a/app/osquery.go +++ b/app/osquery.go @@ -1,11 +1,14 @@ package app import ( + "fmt" "net/http" "time" "github.com/Sirupsen/logrus" "github.com/gin-gonic/gin" + "github.com/jinzhu/gorm" + "github.com/kolide/kolide-ose/config" "github.com/kolide/kolide-ose/errors" ) @@ -146,10 +149,9 @@ type Decorator struct { Query string } -// - type OsqueryEnrollPostBody struct { - EnrollSecret string `json:"enroll_secret" validate:"required"` + EnrollSecret string `json:"enroll_secret" validate:"required"` + HostIdentifier string `json:"host_identifier" validate:"required"` } type OsqueryConfigPostBody struct { @@ -188,6 +190,56 @@ type OsqueryDistributedWritePostBody struct { Queries map[string][]map[string]string `json:"queries" validate:"required"` } +// Generate a node key using NodeKeySize random bytes Base64 encoded +func newNodeKey() (string, error) { + return generateRandomText(config.Osquery.NodeKeySize) +} + +// Enroll a host. Even if this is an existing host, a new node key should be +// generated and saved to the DB. +func EnrollHost(db *gorm.DB, uuid, hostName, ipAddress, platform string) (*Host, error) { + host := Host{UUID: uuid} + err := db.Where(&host).First(&host).Error + if err != nil { + switch err { + case gorm.ErrRecordNotFound: + // Create new Host + host = Host{ + UUID: uuid, + HostName: hostName, + IPAddress: ipAddress, + Platform: platform, + } + + default: + return nil, err + } + } + + // Generate a new key each enrollment + host.NodeKey, err = newNodeKey() + if err != nil { + return nil, err + } + + // Update these fields if provided + if hostName != "" { + host.HostName = hostName + } + if ipAddress != "" { + host.IPAddress = ipAddress + } + if platform != "" { + host.Platform = platform + } + + if err := db.Save(&host).Error; err != nil { + return nil, err + } + + return &host, nil +} + func OsqueryEnroll(c *gin.Context) { var body OsqueryEnrollPostBody err := ParseAndValidateJSON(c, &body) @@ -195,11 +247,31 @@ func OsqueryEnroll(c *gin.Context) { errors.ReturnError(c, err) return } - logrus.Debugf("OsqueryEnroll: %s", body.EnrollSecret) + + if body.EnrollSecret != config.Osquery.EnrollSecret { + errors.ReturnError( + c, + errors.NewWithStatus(http.StatusUnauthorized, + "Node key invalid", + fmt.Sprintf("Invalid node secret provided: %s", body.EnrollSecret), + )) + return + + } + + db := GetDB(c) + + host, err := EnrollHost(db, body.HostIdentifier, "", "", "") + if err != nil { + errors.ReturnError(c, errors.DatabaseError(err)) + return + } + + logrus.Debugf("New host created: %+v", host) c.JSON(http.StatusOK, gin.H{ - "node_key": "7", + "node_key": host.NodeKey, "node_invalid": false, }) } diff --git a/app/osquery_integration_test.go b/app/osquery_integration_test.go new file mode 100644 index 0000000000..7c2328be45 --- /dev/null +++ b/app/osquery_integration_test.go @@ -0,0 +1,143 @@ +package app + +import ( + "encoding/json" + "net/http" + "testing" + + "github.com/kolide/kolide-ose/config" + "github.com/kolide/kolide-ose/errors" + "github.com/stretchr/testify/assert" +) + +func TestIntegrationEnrollHostBadSecret(t *testing.T) { + var req IntegrationRequests + req.New(t) + + config.Osquery.EnrollSecret = "super secret" + + // Check that a bad enroll secret causes the appropriate error code and + // error JSON to be returned + + resp := req.EnrollHost("bad secret", "fake_uuid") + + if resp.Code != http.StatusUnauthorized { + t.Error("Should error with invalid enroll secret") + } + + var body map[string]interface{} + err := json.Unmarshal(resp.Body.Bytes(), &body) + if err != nil { + t.Fatalf("JSON decode error: %s JSON contents:\n %s", err.Error(), resp.Body.Bytes()) + } + + if _, ok := body["node_key"]; ok { + t.Errorf("Should not return node key when secret is invalid") + } +} + +func TestIntegrationEnrollHostMissingIdentifier(t *testing.T) { + var req IntegrationRequests + req.New(t) + + config.Osquery.EnrollSecret = "super secret" + + // Check that an empty host identifier causes the appropriate error code and + // error JSON to be returned + + resp := req.EnrollHost("super secret", "") + + if resp.Code != errors.StatusUnprocessableEntity { + t.Error("Should error with missing host identifier") + } + + var body map[string]interface{} + err := json.Unmarshal(resp.Body.Bytes(), &body) + if err != nil { + t.Fatalf("JSON decode error: %s JSON contents:\n %s", err.Error(), resp.Body.Bytes()) + } + + assert.Equal(t, "Validation error", body["message"]) +} + +func TestIntegrationEnrollHostGood(t *testing.T) { + var req IntegrationRequests + req.New(t) + + config.Osquery.EnrollSecret = "super secret" + + // Make a good request and verify that a node key is returned. Also + // check that the DB recorded the information. + + resp := req.EnrollHost("super secret", "fake_host_1") + + if resp.Code != http.StatusOK { + t.Error("Status should be StatusOK") + } + + t.Logf("Response body:\n%s", string(resp.Body.Bytes())) + + var body map[string]interface{} + err := json.Unmarshal(resp.Body.Bytes(), &body) + if err != nil { + t.Fatalf("JSON decode error: %s JSON contents:\n %s", err.Error(), resp.Body.Bytes()) + } + + if _, ok := body["error"]; ok { + t.Errorf("Unexpected error message: %s", body["error"]) + } + + if invalid, ok := body["node_invalid"]; ok && invalid == true { + t.Errorf("Expected node_invalid = false") + } + + nodeKey, ok := body["node_key"] + if !ok || nodeKey == "" { + t.Errorf("Expected node_key") + } + + var host Host + err = req.db.Where("uuid = ?", "fake_host_1").First(&host).Error + if err != nil { + t.Fatalf("Host not saved to DB: %s", err.Error()) + } + + if host.NodeKey != nodeKey { + t.Errorf("Saved node key different than response key, %s != %s", + host.NodeKey, nodeKey) + } + + // Enroll again and check that node key changes + + resp = req.EnrollHost("super secret", "fake_host_1") + + if resp.Code != http.StatusOK { + t.Error("Status should be StatusOK") + } + + t.Logf("Response body:\n%s", string(resp.Body.Bytes())) + + body = map[string]interface{}{} + err = json.Unmarshal(resp.Body.Bytes(), &body) + if err != nil { + t.Fatalf("JSON decode error: %s JSON contents:\n %s", err.Error(), resp.Body.Bytes()) + } + + if _, ok := body["error"]; ok { + t.Errorf("Unexpected error message: %s", body["error"]) + } + + if invalid, ok := body["node_invalid"]; ok && invalid == true { + t.Errorf("Expected node_invalid = false") + } + + newNodeKey, ok := body["node_key"] + if !ok || nodeKey == "" { + t.Errorf("Expected node_key") + } + + if newNodeKey == nodeKey { + t.Errorf("Node key should have changed, %s == %s", newNodeKey, nodeKey) + } + +} diff --git a/app/osquery_test.go b/app/osquery_test.go new file mode 100644 index 0000000000..a44e7b42ab --- /dev/null +++ b/app/osquery_test.go @@ -0,0 +1,93 @@ +package app + +import ( + "testing" +) + +func TestEnrollHost(t *testing.T) { + db := openTestDB(t) + + expect := Host{ + UUID: "uuid123", + HostName: "fakehostname", + IPAddress: "192.168.1.1", + Platform: "Mac OSX", + } + + host, err := EnrollHost(db, expect.UUID, expect.HostName, expect.IPAddress, expect.Platform) + if err != nil { + t.Fatal(err.Error()) + } + + if host.UUID != expect.UUID { + t.Errorf("UUID not as expected: %s != %s", host.UUID, expect.UUID) + } + + if host.HostName != expect.HostName { + t.Errorf("HostName not as expected: %s != %s", host.HostName, expect.HostName) + } + + if host.IPAddress != expect.IPAddress { + t.Errorf("IPAddress not as expected: %s != %s", host.IPAddress, expect.IPAddress) + } + + if host.Platform != expect.Platform { + t.Errorf("Platform not as expected: %s != %s", host.Platform, expect.Platform) + } + + if host.NodeKey == "" { + t.Error("Node key was not set") + } + +} + +func TestReEnrollHost(t *testing.T) { + db := openTestDB(t) + + expect := Host{ + UUID: "uuid123", + HostName: "fakehostname", + IPAddress: "192.168.1.1", + Platform: "Mac OSX", + } + + host, err := EnrollHost(db, expect.UUID, expect.HostName, expect.IPAddress, expect.Platform) + if err != nil { + t.Fatal(err.Error()) + } + + // Save the node key to check that it changed + oldNodeKey := host.NodeKey + + expect.HostName = "newhostname" + + host, err = EnrollHost(db, expect.UUID, expect.HostName, "", "") + if err != nil { + t.Fatal(err.Error()) + } + + if host.UUID != expect.UUID { + t.Errorf("UUID not as expected: %s != %s", host.UUID, expect.UUID) + } + + if host.HostName != expect.HostName { + t.Errorf("HostName not as expected: %s != %s", host.HostName, expect.HostName) + } + + if host.IPAddress != expect.IPAddress { + t.Errorf("IPAddress not as expected: %s != %s", host.IPAddress, expect.IPAddress) + } + + if host.Platform != expect.Platform { + t.Errorf("Platform not as expected: %s != %s", host.Platform, expect.Platform) + } + + if host.NodeKey == "" { + t.Error("Node key was not set") + } + + if host.NodeKey == oldNodeKey { + t.Error("Node key should have changed") + } + +} diff --git a/app/test_util.go b/app/test_util.go index 176b5526f2..0e56c8c3d5 100644 --- a/app/test_util.go +++ b/app/test_util.go @@ -489,3 +489,23 @@ func (req *IntegrationRequests) SetAdminStateAndCheckUser(username string, admin resp := req.SetAdminState(username, admin, session) req.CheckUser(username, resp.Email, resp.Name, admin, resp.NeedsPasswordReset, resp.Enabled) } + +func (req *IntegrationRequests) EnrollHost(enrollSecret, hostIdentifier string) *httptest.ResponseRecorder { + response := httptest.NewRecorder() + body, err := json.Marshal(OsqueryEnrollPostBody{ + EnrollSecret: enrollSecret, + HostIdentifier: hostIdentifier, + }) + + if err != nil { + req.t.Fatal(err.Error()) + } + + buff := new(bytes.Buffer) + buff.Write(body) + request, _ := http.NewRequest("POST", "/api/v1/osquery/enroll", buff) + request.Header.Set("Content-Type", "application/json") + req.r.ServeHTTP(response, request) + + return response +} diff --git a/config/config.go b/config/config.go index dc72ac2b56..398c887147 100644 --- a/config/config.go +++ b/config/config.go @@ -27,10 +27,16 @@ type AppConfigData struct { SessionExpirationSeconds float64 `json:"session_expiration_seconds"` } +type OsqueryConfigData struct { + EnrollSecret string `json:"enroll_secret"` + NodeKeySize int `json:"node_key_size"` +} + type configData struct { - MySQL MySQLConfigData `json:"mysql"` - Server ServerConfigData `json:"server"` - App AppConfigData `json:"app"` + MySQL MySQLConfigData `json:"mysql"` + Server ServerConfigData `json:"server"` + App AppConfigData `json:"app"` + Osquery OsqueryConfigData `json:"osquery"` } var defaultMySQLConfigData = MySQLConfigData{ @@ -55,6 +61,11 @@ var defaultAppConfigData = AppConfigData{ SessionExpirationSeconds: 60 * 60 * 24 * 90, } +var defaultOsqueryConfigData = OsqueryConfigData{ + EnrollSecret: "bad secret", + NodeKeySize: 24, +} + var defaultConfigData = configData{ MySQL: defaultMySQLConfigData, Server: defaultServerConfigData, @@ -62,15 +73,17 @@ var defaultConfigData = configData{ } var ( - MySQL MySQLConfigData - Server ServerConfigData - App AppConfigData + MySQL MySQLConfigData + Server ServerConfigData + App AppConfigData + Osquery OsqueryConfigData ) func init() { MySQL = defaultMySQLConfigData Server = defaultServerConfigData App = defaultAppConfigData + Osquery = defaultOsqueryConfigData } func LoadConfig(path string) error { @@ -86,5 +99,6 @@ func LoadConfig(path string) error { MySQL = config.MySQL App = config.App Server = config.Server + Osquery = config.Osquery return nil } diff --git a/errors/errors_test.go b/errors/errors_test.go index 95a21b3ed1..b776ceadb4 100644 --- a/errors/errors_test.go +++ b/errors/errors_test.go @@ -149,8 +149,6 @@ func TestReturnErrorValidationError(t *testing.T) { t.Errorf("Should respond with 422, got %d", resp.Code) } - t.Log(resp.Body.String()) - var bodyJson map[string]interface{} if err := json.Unmarshal(resp.Body.Bytes(), &bodyJson); err != nil { t.Errorf("Error unmarshaling JSON: %s", err.Error()) diff --git a/tools/app/example_config.json b/tools/app/example_config.json index c85a455029..6a676cafae 100644 --- a/tools/app/example_config.json +++ b/tools/app/example_config.json @@ -17,6 +17,7 @@ "session_key_size": 64 }, "osquery": { - "enroll_secret": "super secure" + "enroll_secret": "super secure", + "node_key_size": 24 } } diff --git a/tools/osquery/example_osquery.flags b/tools/osquery/example_osquery.flags index 0e69e5068a..066b5cc01e 100644 --- a/tools/osquery/example_osquery.flags +++ b/tools/osquery/example_osquery.flags @@ -5,3 +5,4 @@ --tls_hostname=dockerhost:8080 --tls_server_certs=/etc/osquery/kolide.crt --verbose=true +--host_identifier=uuid \ No newline at end of file