diff --git a/.gitignore b/.gitignore index 19ff0d37f6..594cb09e6c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ kolide.exe kolide kolide-ose* -vendor/ \ No newline at end of file +vendor/ +*.test \ No newline at end of file diff --git a/Godeps/Godeps.json b/Godeps/Godeps.json index f636b9b46c..ec8f9138a1 100644 --- a/Godeps/Godeps.json +++ b/Godeps/Godeps.json @@ -20,6 +20,10 @@ "ImportPath": "github.com/alecthomas/units", "Rev": "2efee857e7cfd4f3d0138cc3cbb1b4966962b93a" }, + { + "ImportPath": "github.com/davecgh/go-spew/spew", + "Rev": "5215b55f46b2b919f50a1df0eaa5886afe4e3b3d" + }, { "ImportPath": "github.com/dgrijalva/jwt-go", "Comment": "v3.0.0-4-g01aeca5", @@ -81,6 +85,16 @@ "Comment": "v1.1.0-71-ge118d44", "Rev": "e118d4451349065b8e7ce0f0af32e033995363f8" }, + { + "ImportPath": "github.com/pmezard/go-difflib/difflib", + "Comment": "v1.0.0", + "Rev": "792786c7400a136282c1664665ae0a8db921c6c2" + }, + { + "ImportPath": "github.com/stretchr/testify/assert", + "Comment": "v1.1.3-19-gd77da35", + "Rev": "d77da356e56a7428ad25149ca77381849a6a5232" + }, { "ImportPath": "golang.org/x/crypto/bcrypt", "Rev": "bc89c496413265e715159bdc8478ee9a92fdc265" diff --git a/app/auth.go b/app/auth.go index 1df44fba87..d261476e0f 100644 --- a/app/auth.go +++ b/app/auth.go @@ -3,13 +3,13 @@ package app import ( "crypto/rand" "encoding/base64" - "errors" "fmt" "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" "golang.org/x/crypto/bcrypt" ) @@ -38,7 +38,7 @@ func (vc *ViewerContext) UserID() (uint, error) { if vc.user != nil { return vc.user.ID, nil } - return 0, errors.New("No user set") + return 0, errors.New("Unauthorized", "No user set") } // CanPerformActions returns a bool indicating the current user's ability to @@ -157,8 +157,8 @@ func SaltAndHashPassword(password string) (string, []byte, error) { // swagger:parameters Login type LoginRequestBody struct { - Username string `json:"username" binding:"required"` - Password string `json:"password" binding:"required"` + Username string `json:"username" validate:"required"` + Password string `json:"password" validate:"required"` } // swagger:route POST /api/v1/kolide/login Login @@ -185,9 +185,9 @@ type LoginRequestBody struct { // 200: GetUserResponseBody func Login(c *gin.Context) { var body LoginRequestBody - err := c.BindJSON(&body) + err := ParseAndValidateJSON(c, &body) if err != nil { - logrus.Errorf("Error parsing Login post body: %s", err.Error()) + errors.ReturnError(c, err) return } @@ -212,7 +212,7 @@ func Login(c *gin.Context) { sm.MakeSessionForUserID(user.ID) err = sm.Save() if err != nil { - DatabaseError(c) + errors.ReturnError(c, errors.DatabaseError(err)) return } @@ -252,13 +252,13 @@ func Logout(c *gin.Context) { err := sm.Destroy() if err != nil { - DatabaseError(c) + errors.ReturnError(c, errors.DatabaseError(err)) return } err = sm.Save() if err != nil { - DatabaseError(c) + errors.ReturnError(c, errors.DatabaseError(err)) return } diff --git a/app/models.go b/app/models.go index f2c248ccf9..a8f533cb8a 100644 --- a/app/models.go +++ b/app/models.go @@ -9,24 +9,23 @@ import ( _ "github.com/jinzhu/gorm/dialects/mysql" _ "github.com/jinzhu/gorm/dialects/sqlite" "github.com/kolide/kolide-ose/config" - "github.com/kolide/kolide-ose/osquery" "github.com/kolide/kolide-ose/sessions" ) var tables = [...]interface{}{ &User{}, &sessions.Session{}, - &osquery.ScheduledQuery{}, - &osquery.Pack{}, - &osquery.DiscoveryQuery{}, - &osquery.Host{}, - &osquery.Label{}, - &osquery.Option{}, - &osquery.Decorator{}, - &osquery.Target{}, - &osquery.DistributedQuery{}, - &osquery.Query{}, - &osquery.DistributedQueryExecution{}, + &ScheduledQuery{}, + &Pack{}, + &DiscoveryQuery{}, + &Host{}, + &Label{}, + &Option{}, + &Decorator{}, + &Target{}, + &DistributedQuery{}, + &Query{}, + &DistributedQueryExecution{}, } func setDBSettings(db *gorm.DB) { diff --git a/app/osquery.go b/app/osquery.go new file mode 100644 index 0000000000..615289bf3d --- /dev/null +++ b/app/osquery.go @@ -0,0 +1,324 @@ +package app + +import ( + "net/http" + "time" + + "github.com/Sirupsen/logrus" + "github.com/gin-gonic/gin" + "github.com/kolide/kolide-ose/errors" +) + +type ScheduledQuery struct { + ID uint `gorm:"primary_key"` + CreatedAt time.Time + UpdatedAt time.Time + Name string `gorm:"not null"` + QueryID int + Query Query + Interval uint `gorm:"not null"` + Snapshot bool + Differential bool + Platform string + PackID uint +} + +type Query struct { + ID uint `gorm:"primary_key"` + CreatedAt time.Time + UpdatedAt time.Time + Query string `gorm:"not null"` + Targets []Target `gorm:"many2many:query_targets"` +} + +type TargetType int + +const ( + TargetLabel TargetType = iota + TargetHost TargetType = iota +) + +type Target struct { + ID uint `gorm:"primary_key"` + CreatedAt time.Time + UpdatedAt time.Time + Type TargetType + QueryID uint + TargetID uint +} + +type DistributedQueryStatus int + +const ( + QueryRunning DistributedQueryStatus = iota + QueryComplete DistributedQueryStatus = iota + QueryError DistributedQueryStatus = iota +) + +type DistributedQuery struct { + ID uint `gorm:"primary_key"` + CreatedAt time.Time + UpdatedAt time.Time + Query Query + MaxDuration time.Duration + Status DistributedQueryStatus + UserID uint +} + +type DistributedQueryExecutionStatus int + +const ( + ExecutionWaiting DistributedQueryExecutionStatus = iota + ExecutionRequested DistributedQueryExecutionStatus = iota + ExecutionSucceeded DistributedQueryExecutionStatus = iota + ExecutionFailed DistributedQueryExecutionStatus = iota +) + +type DistributedQueryExecution struct { + HostID uint + DistributedQueryID uint + Status DistributedQueryExecutionStatus + Error string `gorm:"size:1024"` + ExecutionDuration time.Duration +} + +type Pack struct { + ID uint `gorm:"primary_key"` + CreatedAt time.Time + UpdatedAt time.Time + Name string `gorm:"not null;unique_index:idx_pack_unique_name"` + Platform string + Queries []ScheduledQuery + DiscoveryQueries []DiscoveryQuery +} + +type DiscoveryQuery struct { + ID uint `gorm:"primary_key"` + CreatedAt time.Time + UpdatedAt time.Time + Query string `gorm:"size:1024" gorm:"not null"` +} + +type Host struct { + ID uint `gorm:"primary_key"` + CreatedAt time.Time + UpdatedAt time.Time + NodeKey string `gorm:"unique_index:idx_host_unique_nodekey"` + HostName string + UUID string `gorm:"unique_index:idx_host_unique_uuid"` + IPAddress string + Platform string + Labels []*Label `gorm:"many2many:host_labels;"` +} + +type Label struct { + ID uint `gorm:"primary_key"` + CreatedAt time.Time + UpdatedAt time.Time + Name string `gorm:"not null;unique_index:idx_label_unique_name"` + Query string + Hosts []Host +} + +type Option struct { + ID uint `gorm:"primary_key"` + CreatedAt time.Time + UpdatedAt time.Time + Key string `gorm:"not null;unique_index:idx_option_unique_key"` + Value string `gorm:"not null"` + Platform string +} + +type DecoratorType int + +const ( + DecoratorLoad DecoratorType = iota + DecoratorAlways DecoratorType = iota + DecoratorInterval DecoratorType = iota +) + +type Decorator struct { + ID uint `gorm:"primary_key"` + CreatedAt time.Time + UpdatedAt time.Time + Type DecoratorType `gorm:"not null"` + Interval int + Query string +} + +// + +type OsqueryEnrollPostBody struct { + EnrollSecret string `json:"enroll_secret" validate:"required"` +} + +type OsqueryConfigPostBody struct { + NodeKey string `json:"node_key" validate:"required"` +} + +type OsqueryLogPostBody struct { + NodeKey string `json:"node_key" validate:"required"` + LogType string `json:"log_type" validate:"required"` + Data []map[string]interface{} `json:"data" validate:"required"` +} + +type OsqueryResultLog struct { + Name string `json:"name"` + HostIdentifier string `json:"hostIdentifier"` + UnixTime string `json:"unixTime"` + CalendarTime string `json:"calendarTime"` + Columns map[string]string `json:"columns"` + Action string `json:"action"` +} + +type OsqueryStatusLog struct { + Severity string `json:"severity"` + Filename string `json:"filename"` + Line string `json:"line"` + Message string `json:"message"` + Version string `json:"version"` +} + +type OsqueryDistributedReadPostBody struct { + NodeKey string `json:"node_key" validate:"required"` +} + +type OsqueryDistributedWritePostBody struct { + NodeKey string `json:"node_key" validate:"required"` + Queries map[string][]map[string]string `json:"queries" validate:"required"` +} + +func OsqueryEnroll(c *gin.Context) { + var body OsqueryEnrollPostBody + err := ParseAndValidateJSON(c, &body) + if err != nil { + errors.ReturnError(c, err) + return + } + logrus.Debugf("OsqueryEnroll: %s", body.EnrollSecret) + + c.JSON(http.StatusOK, + gin.H{ + "node_key": "7", + "node_invalid": false, + }) +} + +func OsqueryConfig(c *gin.Context) { + var body OsqueryConfigPostBody + err := ParseAndValidateJSON(c, &body) + if err != nil { + errors.ReturnError(c, err) + return + } + logrus.Debugf("OsqueryConfig: %s", body.NodeKey) + + c.JSON(http.StatusOK, + gin.H{ + "schedule": map[string]map[string]interface{}{ + "time": { + "query": "select * from time;", + "interval": 1, + }, + }, + "node_invalid": false, + }) +} + +func OsqueryLog(c *gin.Context) { + var body OsqueryLogPostBody + err := ParseAndValidateJSON(c, &body) + if err != nil { + errors.ReturnError(c, err) + return + } + logrus.Debugf("OsqueryLog: %s", body.LogType) + + if body.LogType == "status" { + for _, data := range body.Data { + var log OsqueryStatusLog + + severity, ok := data["severity"].(string) + if ok { + log.Severity = severity + } else { + logrus.Error("Error asserting the type of status log severity") + } + + filename, ok := data["filename"].(string) + if ok { + log.Filename = filename + } else { + logrus.Error("Error asserting the type of status log filename") + } + + line, ok := data["line"].(string) + if ok { + log.Line = line + } else { + logrus.Error("Error asserting the type of status log line") + } + + message, ok := data["message"].(string) + if ok { + log.Message = message + } else { + logrus.Error("Error asserting the type of status log message") + } + + version, ok := data["version"].(string) + if ok { + log.Version = version + } else { + logrus.Error("Error asserting the type of status log version") + } + + logrus.WithFields(logrus.Fields{ + "node_key": body.NodeKey, + "severity": log.Severity, + "filename": log.Filename, + "line": log.Line, + "version": log.Version, + }).Info(log.Message) + } + } else if body.LogType == "result" { + // TODO: handle all of the different kinds of results logs + } + + c.JSON(http.StatusOK, + gin.H{ + "node_invalid": false, + }) +} + +func OsqueryDistributedRead(c *gin.Context) { + var body OsqueryDistributedReadPostBody + err := ParseAndValidateJSON(c, &body) + if err != nil { + errors.ReturnError(c, err) + return + } + logrus.Debugf("OsqueryDistributedRead: %s", body.NodeKey) + + c.JSON(http.StatusOK, + gin.H{ + "queries": map[string]string{ + "id1": "select * from osquery_info", + }, + "node_invalid": false, + }) +} + +func OsqueryDistributedWrite(c *gin.Context) { + var body OsqueryDistributedWritePostBody + err := ParseAndValidateJSON(c, &body) + if err != nil { + errors.ReturnError(c, err) + return + } + logrus.Debugf("OsqueryDistributedWrite: %s", body.NodeKey) + c.JSON(http.StatusOK, + gin.H{ + "node_invalid": false, + }) +} diff --git a/app/server.go b/app/server.go index 70c970cee2..065e58a2f0 100644 --- a/app/server.go +++ b/app/server.go @@ -1,7 +1,10 @@ package app import ( + "encoding/json" + "fmt" "io" + "net/http" _ "net/http/pprof" "time" @@ -10,43 +13,32 @@ import ( "github.com/gin-gonic/gin" "github.com/jinzhu/gorm" "github.com/kolide/kolide-ose/config" - "github.com/kolide/kolide-ose/osquery" + "github.com/kolide/kolide-ose/errors" "github.com/kolide/kolide-ose/sessions" + "gopkg.in/go-playground/validator.v8" ) +var validate *validator.Validate = validator.New(&validator.Config{TagName: "validate", FieldNameTag: "json"}) + // Get the database connection from the context, or panic func GetDB(c *gin.Context) *gorm.DB { return c.MustGet("DB").(*gorm.DB) } -// ServerError is a helper which accepts a string error and returns a map in -// format that is required by gin.Context.JSON -func ServerError(e string) *map[string]interface{} { - return &map[string]interface{}{ - "error": e, - } -} - -// DatabaseError emits a response that is appropriate in the event that a -// database failure occurs, a record is not found in the database, etc -func DatabaseError(c *gin.Context) { - c.JSON(500, ServerError("Database error")) -} - // UnauthorizedError emits a response that is appropriate in the event that a // request is received by a user which is not authorized to carry out the // requested action func UnauthorizedError(c *gin.Context) { - c.JSON(401, ServerError("Unauthorized")) -} - -// MalformedRequestError emits a response that is appropriate in the event that -// a request is received by a user which does not have required fields or is in -// some way malformed -func MalformedRequestError(c *gin.Context) { - c.JSON(400, ServerError("Malformed request")) + errors.ReturnError( + c, + errors.NewWithStatus( + http.StatusUnauthorized, + "Unauthorized", + "Unauthorized", + )) } +// Create a new server for testing purposes with no routes attached func createEmptyTestServer(db *gorm.DB) *gin.Engine { server := gin.New() server.Use(DatabaseMiddleware(db)) @@ -73,6 +65,35 @@ func NewSessionManager(c *gin.Context) *sessions.SessionManager { } } +// Unmarshal JSON from the gin context into a struct +func parseJSON(c *gin.Context, obj interface{}) error { + decoder := json.NewDecoder(c.Request.Body) + if err := decoder.Decode(obj); err != nil { + return err + } + return nil +} + +// Parse JSON into a struct with json.Unmarshal, followed by validation with +// the validator library. +func ParseAndValidateJSON(c *gin.Context, obj interface{}) error { + if err := parseJSON(c, obj); err != nil { + return errors.NewFromError(err, http.StatusBadRequest, "JSON parse error") + } + + return validate.Struct(obj) +} + +func NotFound(c *gin.Context) { + errors.ReturnError( + c, + errors.NewWithStatus( + http.StatusNotFound, + "Not found", + fmt.Sprintf("Route not found for request: %+v", c.Request), + )) +} + // CreateServer creates a gin.Engine HTTP server and configures it to be in a // state such that it is ready to serve HTTP requests for the kolide application func CreateServer(db *gorm.DB, w io.Writer) *gin.Engine { @@ -105,6 +126,9 @@ func CreateServer(db *gorm.DB, w io.Writer) *gin.Engine { recoveryLogger.Out = w server.Use(gin.RecoveryWithWriter(recoveryLogger.Writer())) + // Set the 404 route + server.NoRoute(NotFound) + v1 := server.Group("/api/v1") // Kolide application API endpoints @@ -130,11 +154,11 @@ func CreateServer(db *gorm.DB, w io.Writer) *gin.Engine { // osquery API endpoints osq := v1.Group("/osquery") - osq.POST("/enroll", osquery.OsqueryEnroll) - osq.POST("/config", osquery.OsqueryConfig) - osq.POST("/log", osquery.OsqueryLog) - osq.POST("/distributed/read", osquery.OsqueryDistributedRead) - osq.POST("/distributed/write", osquery.OsqueryDistributedWrite) + osq.POST("/enroll", OsqueryEnroll) + osq.POST("/config", OsqueryConfig) + osq.POST("/log", OsqueryLog) + osq.POST("/distributed/read", OsqueryDistributedRead) + osq.POST("/distributed/write", OsqueryDistributedWrite) return server } diff --git a/app/users.go b/app/users.go index f08b6ffcc2..58b2f19c02 100644 --- a/app/users.go +++ b/app/users.go @@ -7,6 +7,7 @@ import ( "github.com/Sirupsen/logrus" "github.com/gin-gonic/gin" "github.com/jinzhu/gorm" + "github.com/kolide/kolide-ose/errors" "github.com/kolide/kolide-ose/sessions" "golang.org/x/crypto/bcrypt" ) @@ -127,9 +128,9 @@ type GetUserResponseBody struct { // 200: GetUserResponseBody func GetUser(c *gin.Context) { var body GetUserRequestBody - err := c.BindJSON(&body) + err := ParseAndValidateJSON(c, &body) if err != nil { - logrus.Errorf("Error parsing GetUser post body: %s", err.Error()) + errors.ReturnError(c, err) return } @@ -145,7 +146,7 @@ func GetUser(c *gin.Context) { user.Username = body.Username err = db.Where(&user).First(&user).Error if err != nil { - DatabaseError(c) + errors.ReturnError(c, errors.DatabaseError(err)) return } @@ -167,9 +168,9 @@ func GetUser(c *gin.Context) { // swagger:parameters CreateUser type CreateUserRequestBody struct { - Username string `json:"username" binding:"required"` - Password string `json:"password" binding:"required"` - Email string `json:"email" binding:"required"` + Username string `json:"username" validate:"required"` + Password string `json:"password" validate:"required"` + Email string `json:"email" validate:"required,email"` Admin bool `json:"admin"` NeedsPasswordReset bool `json:"needs_password_reset"` } @@ -196,9 +197,9 @@ type CreateUserRequestBody struct { // 200: GetUserResponseBody func CreateUser(c *gin.Context) { var body CreateUserRequestBody - err := c.BindJSON(&body) + err := ParseAndValidateJSON(c, &body) if err != nil { - logrus.Errorf("Error parsing CreateUser post body: %s", err.Error()) + errors.ReturnError(c, err) return } @@ -212,7 +213,7 @@ func CreateUser(c *gin.Context) { user, err := NewUser(db, body.Username, body.Password, body.Email, body.Admin, body.NeedsPasswordReset) if err != nil { logrus.Errorf("Error creating new user: %s", err.Error()) - DatabaseError(c) + errors.ReturnError(c, errors.DatabaseError(err)) return } @@ -259,9 +260,9 @@ type ModifyUserRequestBody struct { // 200: GetUserResponseBody func ModifyUser(c *gin.Context) { var body ModifyUserRequestBody - err := c.BindJSON(&body) + err := ParseAndValidateJSON(c, &body) if err != nil { - logrus.Errorf("Error parsing ModifyUser post body: %s", err.Error()) + errors.ReturnError(c, err) return } @@ -278,7 +279,7 @@ func ModifyUser(c *gin.Context) { db := GetDB(c) err = db.Where(&user).First(&user).Error if err != nil { - DatabaseError(c) + errors.ReturnError(c, errors.DatabaseError(err)) return } @@ -296,7 +297,7 @@ func ModifyUser(c *gin.Context) { err = db.Save(&user).Error if err != nil { logrus.Errorf("Error updating user in database: %s", err.Error()) - DatabaseError(c) + errors.ReturnError(c, errors.DatabaseError(err)) return } c.JSON(200, GetUserResponseBody{ @@ -338,9 +339,9 @@ type DeleteUserRequestBody struct { // 200: nil func DeleteUser(c *gin.Context) { var body DeleteUserRequestBody - err := c.BindJSON(&body) + err := ParseAndValidateJSON(c, &body) if err != nil { - logrus.Errorf("Error parsing DeleteUser post body: %s", err.Error()) + errors.ReturnError(c, err) return } @@ -356,14 +357,14 @@ func DeleteUser(c *gin.Context) { user.Username = body.Username err = db.Where(&user).First(&user).Error if err != nil { - DatabaseError(c) + errors.ReturnError(c, errors.DatabaseError(err)) return } err = db.Delete(&user).Error if err != nil { logrus.Errorf("Error deleting user from database: %s", err.Error()) - DatabaseError(c) + errors.ReturnError(c, errors.DatabaseError(err)) return } c.JSON(200, nil) @@ -374,8 +375,8 @@ type ChangePasswordRequestBody struct { ID uint `json:"id"` Username string `json:"username"` CurrentPassword string `json:"current_password"` - NewPassword string `json:"new_password" binding:"required"` - NewPasswordConfim string `json:"new_password_confirm" binding:"required"` + NewPassword string `json:"new_password" validate:"required"` + NewPasswordConfim string `json:"new_password_confirm" validate:"required"` } // swagger:route PATCH /api/v1/kolide/user/password ChangeUserPassword @@ -402,9 +403,9 @@ type ChangePasswordRequestBody struct { // 200: GetUserResponseBody func ChangeUserPassword(c *gin.Context) { var body ChangePasswordRequestBody - err := c.BindJSON(&body) + err := ParseAndValidateJSON(c, &body) if err != nil { - logrus.Errorf("Error parsing ResetPassword post body: %s", err.Error()) + errors.ReturnError(c, err) return } @@ -425,7 +426,7 @@ func ChangeUserPassword(c *gin.Context) { user.Username = body.Username err = db.Where(&user).First(&user).Error if err != nil { - DatabaseError(c) + errors.ReturnError(c, errors.DatabaseError(err)) return } @@ -443,14 +444,14 @@ func ChangeUserPassword(c *gin.Context) { err = user.SetPassword(db, body.NewPassword) if err != nil { logrus.Errorf("Error setting user password: %s", err.Error()) - DatabaseError(c) // probably not this + errors.ReturnError(c, errors.DatabaseError(err)) // probably not this return } err = db.Save(&user).Error if err != nil { logrus.Errorf("Error updating user in database: %s", err.Error()) - DatabaseError(c) + errors.ReturnError(c, errors.DatabaseError(err)) return } c.JSON(200, GetUserResponseBody{ @@ -493,9 +494,9 @@ type SetUserAdminStateRequestBody struct { // 200: GetUserResponseBody func SetUserAdminState(c *gin.Context) { var body SetUserAdminStateRequestBody - err := c.BindJSON(&body) + err := ParseAndValidateJSON(c, &body) if err != nil { - logrus.Errorf("Error parsing SetUserAdminState post body: %s", err.Error()) + errors.ReturnError(c, err) return } @@ -511,7 +512,7 @@ func SetUserAdminState(c *gin.Context) { user.Username = body.Username err = db.Where(&user).First(&user).Error if err != nil { - DatabaseError(c) + errors.ReturnError(c, errors.DatabaseError(err)) return } @@ -519,7 +520,7 @@ func SetUserAdminState(c *gin.Context) { err = db.Save(&user).Error if err != nil { logrus.Errorf("Error updating user in database: %s", err.Error()) - DatabaseError(c) + errors.ReturnError(c, errors.DatabaseError(err)) return } c.JSON(200, GetUserResponseBody{ @@ -562,9 +563,9 @@ type SetUserEnabledStateRequestBody struct { // 200: GetUserResponseBody func SetUserEnabledState(c *gin.Context) { var body SetUserEnabledStateRequestBody - err := c.BindJSON(&body) + err := ParseAndValidateJSON(c, &body) if err != nil { - logrus.Errorf("Error parsing SetUserEnabledState post body: %s", err.Error()) + errors.ReturnError(c, err) return } @@ -580,7 +581,7 @@ func SetUserEnabledState(c *gin.Context) { user.Username = body.Username err = db.Where(&user).First(&user).Error if err != nil { - DatabaseError(c) + errors.ReturnError(c, errors.DatabaseError(err)) return } @@ -588,7 +589,7 @@ func SetUserEnabledState(c *gin.Context) { err = db.Save(&user).Error if err != nil { logrus.Errorf("Error updating user in database: %s", err.Error()) - DatabaseError(c) + errors.ReturnError(c, errors.DatabaseError(err)) return } c.JSON(200, GetUserResponseBody{ @@ -624,7 +625,7 @@ func GetSessionBackend(c *gin.Context) sessions.SessionBackend { // swagger:parameters DeleteSession type DeleteSessionRequestBody struct { - SessionID uint `json:"session_id" binding:"required"` + SessionID uint `json:"session_id" validate:"required"` } // swagger:route DELETE /api/v1/kolide/session DeleteSession @@ -649,9 +650,9 @@ type DeleteSessionRequestBody struct { // 200: nil func DeleteSession(c *gin.Context) { var body DeleteSessionRequestBody - err := c.BindJSON(&body) + err := ParseAndValidateJSON(c, &body) if err != nil { - logrus.Errorf(err.Error()) + errors.ReturnError(c, err) return } @@ -672,7 +673,7 @@ func DeleteSession(c *gin.Context) { user := &User{ID: session.UserID} err = db.Where(user).First(user).Error if err != nil { - DatabaseError(c) + errors.ReturnError(c, errors.DatabaseError(err)) return } @@ -683,7 +684,7 @@ func DeleteSession(c *gin.Context) { err = sb.Destroy(session) if err != nil { - DatabaseError(c) + errors.ReturnError(c, errors.DatabaseError(err)) return } @@ -719,9 +720,10 @@ type DeleteSessionsForUserRequestBody struct { // 200: nil func DeleteSessionsForUser(c *gin.Context) { var body DeleteSessionsForUserRequestBody - err := c.BindJSON(&body) + err := ParseAndValidateJSON(c, &body) if err != nil { - logrus.Errorf(err.Error()) + errors.ReturnError(c, err) + return } vc := VC(c) @@ -736,7 +738,7 @@ func DeleteSessionsForUser(c *gin.Context) { user.Username = body.Username err = db.Where(&user).First(&user).Error if err != nil { - DatabaseError(c) + errors.ReturnError(c, errors.DatabaseError(err)) return } @@ -748,7 +750,7 @@ func DeleteSessionsForUser(c *gin.Context) { sb := GetSessionBackend(c) err = sb.DestroyAllForUser(user.ID) if err != nil { - DatabaseError(c) + errors.ReturnError(c, errors.DatabaseError(err)) return } @@ -758,7 +760,7 @@ func DeleteSessionsForUser(c *gin.Context) { // swagger:parameters GetInfoAboutSession type GetInfoAboutSessionRequestBody struct { - SessionKey string `json:"session_key" binding:"required"` + SessionKey string `json:"session_key" validate:"required"` } // swagger:response SessionInfoResponseBody @@ -791,9 +793,9 @@ type SessionInfoResponseBody struct { // 200: SessionInfoResponseBody func GetInfoAboutSession(c *gin.Context) { var body GetInfoAboutSessionRequestBody - err := c.BindJSON(&body) + err := ParseAndValidateJSON(c, &body) if err != nil { - logrus.Errorf(err.Error()) + errors.ReturnError(c, err) return } @@ -806,7 +808,7 @@ func GetInfoAboutSession(c *gin.Context) { sb := GetSessionBackend(c) session, err := sb.FindKey(body.SessionKey) if err != nil { - DatabaseError(c) + errors.ReturnError(c, errors.DatabaseError(err)) return } @@ -815,7 +817,7 @@ func GetInfoAboutSession(c *gin.Context) { user.ID = session.UserID err = db.Where(&user).First(&user).Error if err != nil { - DatabaseError(c) + errors.ReturnError(c, errors.DatabaseError(err)) return } @@ -865,9 +867,9 @@ type GetInfoAboutSessionsForUserResponseBody struct { // 200: GetInfoAboutSessionsForUserResponseBody func GetInfoAboutSessionsForUser(c *gin.Context) { var body GetInfoAboutSessionsForUserRequestBody - err := c.BindJSON(&body) + err := ParseAndValidateJSON(c, &body) if err != nil { - logrus.Errorf(err.Error()) + errors.ReturnError(c, err) return } @@ -883,7 +885,7 @@ func GetInfoAboutSessionsForUser(c *gin.Context) { user.Username = body.Username err = db.Where(&user).First(&user).Error if err != nil { - DatabaseError(c) + errors.ReturnError(c, errors.DatabaseError(err)) return } @@ -895,7 +897,7 @@ func GetInfoAboutSessionsForUser(c *gin.Context) { sb := GetSessionBackend(c) sessions, err := sb.FindAllForUser(user.ID) if err != nil { - DatabaseError(c) + errors.ReturnError(c, errors.DatabaseError(err)) return } diff --git a/errors/errors.go b/errors/errors.go new file mode 100644 index 0000000000..de194cd402 --- /dev/null +++ b/errors/errors.go @@ -0,0 +1,104 @@ +package errors + +import ( + "net/http" + + "github.com/Sirupsen/logrus" + "github.com/gin-gonic/gin" + "github.com/jinzhu/gorm" + "gopkg.in/go-playground/validator.v8" +) + +// Kolide's internal representation for errors. It can be used to wrap another +// error (stored in Err), and additionally contains fields for public +// (PublicMessage) and private (PrivateMessage) error messages as well as the +// HTTP status code (StatusCode) corresponding to the error. +type KolideError struct { + Err error + StatusCode int + PublicMessage string + PrivateMessage string +} + +// Implementation of error interface +func (e *KolideError) Error() string { + return e.PublicMessage +} + +// Create a new KolideError specifying the public and private messages. The +// status code will be set to 500. +func New(publicMessage, privateMessage string) *KolideError { + return &KolideError{ + StatusCode: http.StatusInternalServerError, + PublicMessage: publicMessage, + PrivateMessage: privateMessage, + } +} + +// Create a new KolideError specifying the HTTP status, and public and private +// messages. +func NewWithStatus(status int, publicMessage, privateMessage string) *KolideError { + return &KolideError{ + StatusCode: status, + PublicMessage: publicMessage, + PrivateMessage: privateMessage, + } +} + +// Create a new KolideError from an error type. The public message and status +// code should be specified, while the private message will be drawn from +// err.Error() +func NewFromError(err error, status int, publicMessage string) *KolideError { + return &KolideError{ + Err: err, + StatusCode: status, + PublicMessage: publicMessage, + PrivateMessage: err.Error(), + } +} + +// Wrap a DB error wit the extra KolideError decorations +func DatabaseError(err error) *KolideError { + return NewFromError(err, http.StatusInternalServerError, "Database error") +} + +// The status code returned for validation errors. Inspired by the Github API. +const StatusUnprocessableEntity = 422 + +// Handle an error, printing debug information, writing to the HTTP response as +// appropriate for the dynamic error type. +func ReturnError(c *gin.Context, err error) { + switch typedErr := err.(type) { + + case *KolideError: + c.JSON(typedErr.StatusCode, + gin.H{"message": typedErr.PublicMessage}) + logrus.WithError(typedErr.Err).Debug(typedErr.PrivateMessage) + + case validator.ValidationErrors: + errors := make([](map[string]string), 0, len(typedErr)) + for _, fieldErr := range typedErr { + m := make(map[string]string) + m["field"] = fieldErr.Name + m["code"] = "invalid" + m["message"] = fieldErr.Tag + errors = append(errors, m) + } + + c.JSON(StatusUnprocessableEntity, + gin.H{"message": "Validation error", + "errors": errors, + }) + logrus.WithError(typedErr).Debug("Validation error") + + case gorm.Errors, *gorm.Errors: + c.JSON(http.StatusInternalServerError, + gin.H{"message": "Database error"}) + logrus.WithError(typedErr).Debug(typedErr.Error()) + + default: + c.JSON(http.StatusInternalServerError, + gin.H{"message": "Unspecified error"}) + logrus.WithError(typedErr).Debug("Unspecified error") + } +} diff --git a/errors/errors_test.go b/errors/errors_test.go new file mode 100644 index 0000000000..95a21b3ed1 --- /dev/null +++ b/errors/errors_test.go @@ -0,0 +1,211 @@ +package errors + +import ( + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "sort" + "testing" + + "github.com/gin-gonic/gin" + "github.com/jinzhu/gorm" + "github.com/stretchr/testify/assert" + "gopkg.in/go-playground/validator.v8" +) + +func TestNew(t *testing.T) { + kolideErr := New("Public message", "Private message") + + expect := &KolideError{ + Err: nil, + StatusCode: http.StatusInternalServerError, + PublicMessage: "Public message", + PrivateMessage: "Private message", + } + assert.Equal(t, expect, kolideErr) +} + +func TestNewWithStatus(t *testing.T) { + kolideErr := NewWithStatus(http.StatusUnauthorized, "Public message", "Private message") + + expect := &KolideError{ + Err: nil, + StatusCode: http.StatusUnauthorized, + PublicMessage: "Public message", + PrivateMessage: "Private message", + } + assert.Equal(t, expect, kolideErr) +} + +func TestNewFromError(t *testing.T) { + err := errors.New("Foo error") + kolideErr := NewFromError(err, StatusUnprocessableEntity, "Public error") + + assert.Equal(t, "Public error", kolideErr.Error()) + + expect := &KolideError{ + Err: err, + StatusCode: StatusUnprocessableEntity, + PublicMessage: "Public error", + PrivateMessage: "Foo error", + } + assert.Equal(t, expect, kolideErr) +} + +func TestDatabaseError(t *testing.T) { + err := errors.New("Foo error") + kolideErr := DatabaseError(err) + + expect := &KolideError{ + Err: err, + StatusCode: http.StatusInternalServerError, + PublicMessage: "Database error", + PrivateMessage: "Foo error", + } + assert.Equal(t, expect, kolideErr) +} + +func TestReturnErrorUnspecified(t *testing.T) { + r := gin.New() + r.POST("/foo", func(c *gin.Context) { + ReturnError(c, errors.New("foo")) + }) + + req, _ := http.NewRequest("POST", "/foo", nil) + resp := httptest.NewRecorder() + + r.ServeHTTP(resp, req) + + if resp.Code != http.StatusInternalServerError { + t.Errorf("Should respond with 500, got %d", resp.Code) + } + + expect := `{"message": "Unspecified error"}` + assert.JSONEq(t, expect, resp.Body.String()) +} + +func TestReturnErrorKolideError(t *testing.T) { + r := gin.New() + r.POST("/foo", func(c *gin.Context) { + ReturnError(c, &KolideError{ + StatusCode: http.StatusUnauthorized, + PublicMessage: "Some error", + }) + }) + + req, _ := http.NewRequest("POST", "/foo", nil) + resp := httptest.NewRecorder() + + r.ServeHTTP(resp, req) + + if resp.Code != http.StatusUnauthorized { + t.Errorf("Should respond with 403, got %d", resp.Code) + } + + expect := `{"message": "Some error"}` + assert.JSONEq(t, expect, resp.Body.String()) +} + +// These types and functions for performing an unordered comparison on a +// []map[string]string] as parsed from the error JSON +type errorField map[string]string +type errorFields []errorField + +func (e errorFields) Len() int { + return len(e) +} + +func (e errorFields) Less(i, j int) bool { + return e[i]["field"] <= e[j]["field"] && + e[i]["code"] <= e[j]["code"] && + e[i]["message"] <= e[j]["message"] +} + +func (e errorFields) Swap(i, j int) { + e[i], e[j] = e[j], e[i] +} + +func TestReturnErrorValidationError(t *testing.T) { + r := gin.New() + + type Foo struct { + Email string `json:"email_foo" validate:"required,email"` + Password string `json:"password" validate:"required"` + } + + validate := validator.New(&validator.Config{TagName: "validate", FieldNameTag: "json"}) + + r.POST("/foo", func(c *gin.Context) { + ReturnError(c, validate.Struct(&Foo{Email: "foo", Password: ""})) + }) + + req, _ := http.NewRequest("POST", "/foo", nil) + resp := httptest.NewRecorder() + + r.ServeHTTP(resp, req) + + if resp.Code != StatusUnprocessableEntity { + 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()) + } + + assert.Equal(t, "Validation error", bodyJson["message"]) + + fields, ok := bodyJson["errors"].([]interface{}) + if !ok { + t.Errorf("Unexpected type for errors") + } + + // The error fields must be copied from []interface{} to + // []map[string][string] before we can sort + compFields := make(errorFields, 0, 0) + for _, field := range fields { + field := field.(map[string]interface{}) + compFields = append( + compFields, + errorField{ + "code": field["code"].(string), + "field": field["field"].(string), + "message": field["message"].(string), + }) + } + + expect := errorFields{ + {"code": "invalid", "field": "email_foo", "message": "email"}, + {"code": "invalid", "field": "password", "message": "required"}, + } + + // Sort to standardize ordering before comparison + sort.Sort(compFields) + sort.Sort(expect) + + assert.Equal(t, expect, compFields) +} + +func TestReturnErrorGormError(t *testing.T) { + r := gin.New() + + r.POST("/foo", func(c *gin.Context) { + err := gorm.Errors{} + err.Add(gorm.ErrInvalidSQL) + ReturnError(c, err) + }) + + req, _ := http.NewRequest("POST", "/foo", nil) + resp := httptest.NewRecorder() + + r.ServeHTTP(resp, req) + + if resp.Code != http.StatusInternalServerError { + t.Errorf("Should respond with 500, got %d", resp.Code) + } + + assert.JSONEq(t, `{"message": "Database error"}`, resp.Body.String()) +} diff --git a/osquery/models.go b/osquery/models.go deleted file mode 100644 index d8385cf246..0000000000 --- a/osquery/models.go +++ /dev/null @@ -1,140 +0,0 @@ -package osquery - -import "time" - -type ScheduledQuery struct { - ID uint `gorm:"primary_key"` - CreatedAt time.Time - UpdatedAt time.Time - Name string `gorm:"not null"` - QueryID int - Query Query - Interval uint `gorm:"not null"` - Snapshot bool - Differential bool - Platform string - PackID uint -} - -type Query struct { - ID uint `gorm:"primary_key"` - CreatedAt time.Time - UpdatedAt time.Time - Query string `gorm:"not null"` - Targets []Target `gorm:"many2many:query_targets"` -} - -type TargetType int - -const ( - TargetLabel TargetType = iota - TargetHost TargetType = iota -) - -type Target struct { - ID uint `gorm:"primary_key"` - CreatedAt time.Time - UpdatedAt time.Time - Type TargetType - QueryID uint - TargetID uint -} - -type DistributedQueryStatus int - -const ( - QueryRunning DistributedQueryStatus = iota - QueryComplete DistributedQueryStatus = iota - QueryError DistributedQueryStatus = iota -) - -type DistributedQuery struct { - ID uint `gorm:"primary_key"` - CreatedAt time.Time - UpdatedAt time.Time - Query Query - MaxDuration time.Duration - Status DistributedQueryStatus - UserID uint -} - -type DistributedQueryExecutionStatus int - -const ( - ExecutionWaiting DistributedQueryExecutionStatus = iota - ExecutionRequested DistributedQueryExecutionStatus = iota - ExecutionSucceeded DistributedQueryExecutionStatus = iota - ExecutionFailed DistributedQueryExecutionStatus = iota -) - -type DistributedQueryExecution struct { - HostID uint - DistributedQueryID uint - Status DistributedQueryExecutionStatus - Error string `gorm:"size:1024"` - ExecutionDuration time.Duration -} - -type Pack struct { - ID uint `gorm:"primary_key"` - CreatedAt time.Time - UpdatedAt time.Time - Name string `gorm:"not null;unique_index:idx_pack_unique_name"` - Platform string - Queries []ScheduledQuery - DiscoveryQueries []DiscoveryQuery -} - -type DiscoveryQuery struct { - ID uint `gorm:"primary_key"` - CreatedAt time.Time - UpdatedAt time.Time - Query string `gorm:"size:1024" gorm:"not null"` -} - -type Host struct { - ID uint `gorm:"primary_key"` - CreatedAt time.Time - UpdatedAt time.Time - NodeKey string `gorm:"unique_index:idx_host_unique_nodekey"` - HostName string - UUID string `gorm:"unique_index:idx_host_unique_uuid"` - IPAddress string - Platform string - Labels []*Label `gorm:"many2many:host_labels;"` -} - -type Label struct { - ID uint `gorm:"primary_key"` - CreatedAt time.Time - UpdatedAt time.Time - Name string `gorm:"not null;unique_index:idx_label_unique_name"` - Query string - Hosts []Host -} - -type Option struct { - ID uint `gorm:"primary_key"` - CreatedAt time.Time - UpdatedAt time.Time - Key string `gorm:"not null;unique_index:idx_option_unique_key"` - Value string `gorm:"not null"` - Platform string -} - -type DecoratorType int - -const ( - DecoratorLoad DecoratorType = iota - DecoratorAlways DecoratorType = iota - DecoratorInterval DecoratorType = iota -) - -type Decorator struct { - ID uint `gorm:"primary_key"` - CreatedAt time.Time - UpdatedAt time.Time - Type DecoratorType `gorm:"not null"` - Interval int - Query string -} diff --git a/osquery/osquery.go b/osquery/osquery.go deleted file mode 100644 index 3554e8580e..0000000000 --- a/osquery/osquery.go +++ /dev/null @@ -1,183 +0,0 @@ -package osquery - -import ( - "net/http" - - "github.com/Sirupsen/logrus" - "github.com/gin-gonic/gin" -) - -type OsqueryEnrollPostBody struct { - EnrollSecret string `json:"enroll_secret" binding:"required"` -} - -type OsqueryConfigPostBody struct { - NodeKey string `json:"node_key" binding:"required"` -} - -type OsqueryLogPostBody struct { - NodeKey string `json:"node_key" binding:"required"` - LogType string `json:"log_type" binding:"required"` - Data []map[string]interface{} `json:"data" binding:"required"` -} - -type OsqueryResultLog struct { - Name string `json:"name"` - HostIdentifier string `json:"hostIdentifier"` - UnixTime string `json:"unixTime"` - CalendarTime string `json:"calendarTime"` - Columns map[string]string `json:"columns"` - Action string `json:"action"` -} - -type OsqueryStatusLog struct { - Severity string `json:"severity"` - Filename string `json:"filename"` - Line string `json:"line"` - Message string `json:"message"` - Version string `json:"version"` -} - -type OsqueryDistributedReadPostBody struct { - NodeKey string `json:"node_key" binding:"required"` -} - -type OsqueryDistributedWritePostBody struct { - NodeKey string `json:"node_key" binding:"required"` - Queries map[string][]map[string]string `json:"queries" binding:"required"` -} - -func OsqueryEnroll(c *gin.Context) { - var body OsqueryEnrollPostBody - err := c.BindJSON(&body) - if err != nil { - logrus.Debugf("Error parsing OsqueryEnroll POST body: %s", err.Error()) - return - } - logrus.Debugf("OsqueryEnroll: %s", body.EnrollSecret) - - c.JSON(http.StatusOK, - gin.H{ - "node_key": "7", - "node_invalid": false, - }) -} - -func OsqueryConfig(c *gin.Context) { - var body OsqueryConfigPostBody - err := c.BindJSON(&body) - if err != nil { - logrus.Debugf("Error parsing OsqueryConfig POST body: %s", err.Error()) - return - } - logrus.Debugf("OsqueryConfig: %s", body.NodeKey) - - c.JSON(http.StatusOK, - gin.H{ - "schedule": map[string]map[string]interface{}{ - "time": { - "query": "select * from time;", - "interval": 1, - }, - }, - "node_invalid": false, - }) -} - -func OsqueryLog(c *gin.Context) { - var body OsqueryLogPostBody - err := c.BindJSON(&body) - if err != nil { - logrus.Debugf("Error parsing OsqueryLog POST body: %s", err.Error()) - return - } - logrus.Debugf("OsqueryLog: %s", body.LogType) - - if body.LogType == "status" { - for _, data := range body.Data { - var log OsqueryStatusLog - - severity, ok := data["severity"].(string) - if ok { - log.Severity = severity - } else { - logrus.Error("Error asserting the type of status log severity") - } - - filename, ok := data["filename"].(string) - if ok { - log.Filename = filename - } else { - logrus.Error("Error asserting the type of status log filename") - } - - line, ok := data["line"].(string) - if ok { - log.Line = line - } else { - logrus.Error("Error asserting the type of status log line") - } - - message, ok := data["message"].(string) - if ok { - log.Message = message - } else { - logrus.Error("Error asserting the type of status log message") - } - - version, ok := data["version"].(string) - if ok { - log.Version = version - } else { - logrus.Error("Error asserting the type of status log version") - } - - logrus.WithFields(logrus.Fields{ - "node_key": body.NodeKey, - "severity": log.Severity, - "filename": log.Filename, - "line": log.Line, - "version": log.Version, - }).Info(log.Message) - } - } else if body.LogType == "result" { - // TODO: handle all of the different kinds of results logs - } - - c.JSON(http.StatusOK, - gin.H{ - "node_invalid": false, - }) -} - -func OsqueryDistributedRead(c *gin.Context) { - var body OsqueryDistributedReadPostBody - err := c.BindJSON(&body) - if err != nil { - logrus.Debugf("Error parsing OsqueryDistributedRead POST body: %s", err.Error()) - return - } - logrus.Debugf("OsqueryDistributedRead: %s", body.NodeKey) - - c.JSON(http.StatusOK, - gin.H{ - "queries": map[string]string{ - "id1": "select * from osquery_info", - }, - "node_invalid": false, - }) -} - -func OsqueryDistributedWrite(c *gin.Context) { - var body OsqueryDistributedWritePostBody - err := c.BindJSON(&body) - if err != nil { - logrus.Debugf("Error parsing OsqueryDistributedWrite POST body: %s", err.Error()) - return - } - logrus.Debugf("OsqueryDistributedWrite: %s", body.NodeKey) - c.JSON(http.StatusOK, - gin.H{ - "node_invalid": false, - }) -} diff --git a/sessions/sessions.go b/sessions/sessions.go index 3c3aeed693..e95e9ae838 100644 --- a/sessions/sessions.go +++ b/sessions/sessions.go @@ -1,30 +1,32 @@ package sessions import ( - "errors" "net/http" "time" "github.com/Sirupsen/logrus" "github.com/dgrijalva/jwt-go" + "github.com/kolide/kolide-ose/errors" ) +const publicErrorMessage string = "Session error" + var ( // An error returned by SessionBackend.Get() if no session record was found // in the database - ErrNoActiveSession = errors.New("Active session is not present in the database") + ErrNoActiveSession = errors.New(publicErrorMessage, "Active session is not present in the database") // An error returned by SessionBackend methods when no session object has // been created yet but the requested action requires one - ErrSessionNotCreated = errors.New("The session has not been created") + ErrSessionNotCreated = errors.New(publicErrorMessage, "The session has not been created") // An error returned by SessionBackend.Get() when a session is requested but // it has expired - ErrSessionExpired = errors.New("The session has expired") + ErrSessionExpired = errors.New(publicErrorMessage, "The session has expired") // An error returned by SessionBackend which indicates that the token // or it's content were malformed - ErrSessionMalformed = errors.New("The session token was malformed") + ErrSessionMalformed = errors.New(publicErrorMessage, "The session token was malformed") ) var ( @@ -205,7 +207,7 @@ func ParseJWT(token string) (*jwt.Token, error) { return jwt.Parse(token, func(t *jwt.Token) (interface{}, error) { method, ok := t.Method.(*jwt.SigningMethodHMAC) if !ok || method != jwt.SigningMethodHS256 { - return nil, errors.New("Unexpected signing method") + return nil, errors.New(publicErrorMessage, "Unexpected signing method") } return []byte(jwtKey), nil })