diff --git a/functions/core/falsy.go b/functions/core/falsy.go index 45d19855..a5e089b3 100644 --- a/functions/core/falsy.go +++ b/functions/core/falsy.go @@ -5,6 +5,7 @@ package core import ( "fmt" + "github.com/daveshanley/vacuum/model" "github.com/pb33f/libopenapi/utils" "gopkg.in/yaml.v3" @@ -39,7 +40,7 @@ func (f Falsy) RunRule(nodes []*yaml.Node, context model.RuleFunctionContext) [] fieldNode, fieldNodeValue := utils.FindKeyNode(context.RuleAction.Field, node.Content) if (fieldNode != nil && fieldNodeValue != nil) && - (fieldNodeValue.Value != "" && fieldNodeValue.Value != "false" && fieldNodeValue.Value != "0") { + (fieldNodeValue.Value != "" && fieldNodeValue.Value != "false" && fieldNodeValue.Value != "0" || (fieldNodeValue.Value == "" && fieldNodeValue.Content != nil)) { results = append(results, model.RuleFunctionResult{ Message: fmt.Sprintf("%s: '%s' must be falsy", context.Rule.Description, context.RuleAction.Field), StartNode: node, diff --git a/functions/core/falsy_test.go b/functions/core/falsy_test.go index f10812ff..6edcca63 100644 --- a/functions/core/falsy_test.go +++ b/functions/core/falsy_test.go @@ -1,10 +1,11 @@ package core import ( + "testing" + "github.com/daveshanley/vacuum/model" "github.com/pb33f/libopenapi/utils" "github.com/stretchr/testify/assert" - "testing" ) func TestFalsy_RunRule_Fail(t *testing.T) { @@ -16,12 +17,15 @@ tags: - name: "non-falsy tag 2" description: 1 - name: "non-falsy tag 3" - description: "hello"` + description: "hello" + - name: "non-falsy tag 4" + description: + hello: goodbye` path := "$.tags[*]" nodes, _ := utils.FindNodes([]byte(sampleYaml), path) - assert.Len(t, nodes, 3) + assert.Len(t, nodes, 4) rule := buildCoreTestRule(path, model.SeverityError, "falsy", "description", nil) ctx := buildCoreTestContext(model.CastToRuleAction(rule.Then), nil) @@ -31,7 +35,7 @@ tags: tru := Falsy{} res := tru.RunRule(nodes, ctx) - assert.Len(t, res, 3) + assert.Len(t, res, 4) } func TestFalsy_RunRule_Fail_NoNodes(t *testing.T) { @@ -72,12 +76,14 @@ tags: - name: "falsy tag 3" description: "" - name: "falsy Tag 4" - description: "0"` + description: "0" + - name: "falsy Tag 5" + description:` path := "$.tags[*]" nodes, _ := utils.FindNodes([]byte(sampleYaml), path) - assert.Len(t, nodes, 4) + assert.Len(t, nodes, 5) rule := buildCoreTestRule(path, model.SeverityError, "Falsy", "description", nil) ctx := buildCoreTestContext(model.CastToRuleAction(rule.Then), nil) diff --git a/functions/core/schema.go b/functions/core/schema.go index c0bf8612..672a98ca 100644 --- a/functions/core/schema.go +++ b/functions/core/schema.go @@ -5,6 +5,7 @@ package core import ( "fmt" + "github.com/daveshanley/vacuum/model" "github.com/daveshanley/vacuum/parser" validationErrors "github.com/pb33f/libopenapi-validator/errors" @@ -81,6 +82,13 @@ func (sch Schema) RunRule(nodes []*yaml.Node, context model.RuleFunctionContext) schema = highBase.NewSchema(&lowSchema) } + // use the current node to validate (field not needed) + forceValidationOnCurrentNode := utils.ExtractValueFromInterfaceMap("forceValidationOnCurrentNode", context.Options) + if _, ok := forceValidationOnCurrentNode.(bool); ok && len(nodes) > 0 { + results = append(results, validateNodeAgainstSchema(schema, nodes[0], context, 0)...) + return results + } + for x, node := range nodes { if x%2 == 0 && len(nodes) > 1 { continue diff --git a/functions/functions.go b/functions/functions.go index c2d95c4e..1276a166 100644 --- a/functions/functions.go +++ b/functions/functions.go @@ -4,11 +4,13 @@ package functions import ( + "sync" + "github.com/daveshanley/vacuum/functions/core" openapi_functions "github.com/daveshanley/vacuum/functions/openapi" + "github.com/daveshanley/vacuum/functions/owasp" "github.com/daveshanley/vacuum/model" "github.com/daveshanley/vacuum/plugin" - "sync" ) type customFunction struct { @@ -92,6 +94,11 @@ func MapBuiltinFunctions() Functions { funcs["pathsKebabCase"] = openapi_functions.PathsKebabCase{} funcs["oasOpErrorResponse"] = openapi_functions.Operation4xResponse{} + // add owasp functions used by the owasp rules + funcs["owaspHeaderDefinition"] = owasp.HeaderDefinition{} + funcs["owaspDefineErrorDefinition"] = owasp.DefineErrorDefinition{} + funcs["owaspCheckSecurity"] = owasp.CheckSecurity{} + }) return functionsSingleton diff --git a/functions/functions_test.go b/functions/functions_test.go index eebe4876..a233d167 100644 --- a/functions/functions_test.go +++ b/functions/functions_test.go @@ -1,11 +1,12 @@ package functions import ( - "github.com/stretchr/testify/assert" "testing" + + "github.com/stretchr/testify/assert" ) func TestMapBuiltinFunctions(t *testing.T) { funcs := MapBuiltinFunctions() - assert.Len(t, funcs.GetAllFunctions(), 42) + assert.Len(t, funcs.GetAllFunctions(), 45) } diff --git a/functions/owasp/check_security.go b/functions/owasp/check_security.go new file mode 100644 index 00000000..76d8ec0d --- /dev/null +++ b/functions/owasp/check_security.go @@ -0,0 +1,115 @@ +package owasp + +import ( + "fmt" + + "github.com/daveshanley/vacuum/model" + "github.com/pb33f/libopenapi/utils" + "golang.org/x/exp/slices" + "gopkg.in/yaml.v3" +) + +type CheckSecurity struct { +} + +// GetSchema returns a model.RuleFunctionSchema defining the schema of the CheckSecurity rule. +func (cd CheckSecurity) GetSchema() model.RuleFunctionSchema { + return model.RuleFunctionSchema{Name: "check_security"} +} + +// RunRule will execute the CheckSecurity rule, based on supplied context and a supplied []*yaml.Node slice. +func (cd CheckSecurity) RunRule(nodes []*yaml.Node, context model.RuleFunctionContext) []model.RuleFunctionResult { + if len(nodes) <= 0 { + return nil + } + + var nullable bool + nullableMap := utils.ExtractValueFromInterfaceMap("nullable", context.Options) + if castedNullable, ok := nullableMap.(bool); ok { + nullable = castedNullable + } + + var methods []string + methodsMap := utils.ExtractValueFromInterfaceMap("methods", context.Options) + if castedMethods, ok := methodsMap.([]string); ok { + methods = castedMethods + } + + // security at the global level replaces if not defined at the operation level + _, valueOfSecurityGlobalNode := utils.FindFirstKeyNode("security", nodes, 0) + + var results []model.RuleFunctionResult + _, valueOfPathNode := utils.FindFirstKeyNode("paths", nodes, 0) + if valueOfPathNode == nil { + return nil + } + + for i := 1; i < len(valueOfPathNode.Content); i += 2 { + for j := 0; j < len(valueOfPathNode.Content[i].Content); j += 2 { + if slices.Contains([]string{ + "get", + "head", + "post", + "put", + "patch", + "delete", + "options", + "trace", + }, valueOfPathNode.Content[i].Content[j].Value) && slices.Contains(methods, valueOfPathNode.Content[i].Content[j].Value) && len(valueOfPathNode.Content[i].Content) > j+1 { + operation := valueOfPathNode.Content[i].Content[j+1] + results = append(results, checkSecurityRule(operation, valueOfSecurityGlobalNode, nullable, valueOfPathNode.Content[i-1].Value, valueOfPathNode.Content[i].Content[j].Value, context)...) + } + } + } + + return results +} + +func checkSecurityRule(operation *yaml.Node, valueOfSecurityGlobalNode *yaml.Node, nullable bool, pathPrefix, method string, context model.RuleFunctionContext) []model.RuleFunctionResult { + _, valueOfSecurityNode := utils.FindFirstKeyNode("security", operation.Content, 0) + if valueOfSecurityNode == nil { // if not defined at the operation level, use global + valueOfSecurityNode = valueOfSecurityGlobalNode + } + if valueOfSecurityNode == nil { + return []model.RuleFunctionResult{ + { + Message: fmt.Sprintf("security' was not defined: for path %q in method %q.", pathPrefix, method), + StartNode: operation, + EndNode: operation, + Path: fmt.Sprintf("$.paths.%s.%s", pathPrefix, method), + Rule: context.Rule, + }, + } + } + if len(valueOfSecurityNode.Content) == 0 { + return []model.RuleFunctionResult{ + { + Message: fmt.Sprintf("'security' is empty: for path %q in method %q.", pathPrefix, method), + StartNode: valueOfSecurityNode, + EndNode: valueOfSecurityNode, + Path: fmt.Sprintf("$.paths.%s.%s.security", pathPrefix, method), + Rule: context.Rule, + }, + } + } + if valueOfSecurityNode.Kind == yaml.SequenceNode { + var results []model.RuleFunctionResult + for k := 0; k < len(valueOfSecurityNode.Content); k++ { + if valueOfSecurityNode.Content[k].Kind != yaml.MappingNode { + continue + } + if len(valueOfSecurityNode.Content[k].Content) == 0 && !nullable { + results = append(results, model.RuleFunctionResult{ + Message: fmt.Sprintf("'security' has null elements: for path %q in method %q with element.", pathPrefix, method), + StartNode: valueOfSecurityNode.Content[k], + EndNode: utils.FindLastChildNodeWithLevel(valueOfSecurityNode.Content[k], 0), + Path: fmt.Sprintf("$.paths.%s.%s.security", pathPrefix, method), + Rule: context.Rule, + }) + } + } + return results + } + + return nil +} diff --git a/functions/owasp/check_security_test.go b/functions/owasp/check_security_test.go new file mode 100644 index 00000000..539df8b1 --- /dev/null +++ b/functions/owasp/check_security_test.go @@ -0,0 +1,55 @@ +package owasp + +import ( + "testing" + + "github.com/daveshanley/vacuum/model" + "github.com/pb33f/libopenapi/utils" + "github.com/stretchr/testify/assert" +) + +func TestCheckSecurity_GetSchema(t *testing.T) { + def := CheckSecurity{} + assert.Equal(t, "check_security", def.GetSchema().Name) +} + +func TestCheckSecurity_RunRule(t *testing.T) { + def := CheckSecurity{} + res := def.RunRule(nil, model.RuleFunctionContext{}) + assert.Len(t, res, 0) +} + +func TestCheckSecurity_SecurityMissing(t *testing.T) { + + yml := `openapi: 3.0.1 +info: + version: "1.2.3" + title: "securitySchemes" +paths: + /security-gloabl-ok-put: + put: + responses: {} + /security-ok-put: + put: + responses: {} +components: + securitySchemes: + BasicAuth: + type: http + scheme: basic` + + path := "$" + + nodes, _ := utils.FindNodes([]byte(yml), path) + + rule := buildOpenApiTestRuleAction(path, "check_security", "", nil) + ctx := buildOpenApiTestContext(model.CastToRuleAction(rule.Then), map[string]interface{}{ + "methods": []string{"put"}, + }) + + def := CheckSecurity{} + res := def.RunRule(nodes, ctx) + + assert.Len(t, res, 2) + +} diff --git a/functions/owasp/define_error_definition.go b/functions/owasp/define_error_definition.go new file mode 100644 index 00000000..c0c823cf --- /dev/null +++ b/functions/owasp/define_error_definition.go @@ -0,0 +1,45 @@ +package owasp + +import ( + "fmt" + "strings" + + "github.com/daveshanley/vacuum/model" + "github.com/pb33f/libopenapi/utils" + "gopkg.in/yaml.v3" +) + +type DefineErrorDefinition struct { +} + +// GetSchema returns a model.RuleFunctionSchema defining the schema of the DefineError rule. +func (cd DefineErrorDefinition) GetSchema() model.RuleFunctionSchema { + return model.RuleFunctionSchema{Name: "define_error_definition"} +} + +// RunRule will execute the DefineError rule, based on supplied context and a supplied []*yaml.Node slice. +func (cd DefineErrorDefinition) RunRule(nodes []*yaml.Node, context model.RuleFunctionContext) []model.RuleFunctionResult { + + if len(nodes) <= 0 { + return nil + } + + var responseCode string + for i, node := range nodes[0].Content { + if i%2 == 0 { + responseCode = node.Value + } else if responseCode == "400" || responseCode == "422" || strings.ToUpper(responseCode) == "4XX" { + return []model.RuleFunctionResult{} + } + } + + return []model.RuleFunctionResult{ + { + Message: "Error '400', '422' or '4XX' was not defined", + StartNode: nodes[0], + EndNode: utils.FindLastChildNodeWithLevel(nodes[0], 0), + Path: fmt.Sprintf("%s", context.Given), + Rule: context.Rule, + }, + } +} diff --git a/functions/owasp/define_error_definition_test.go b/functions/owasp/define_error_definition_test.go new file mode 100644 index 00000000..a73d2dfa --- /dev/null +++ b/functions/owasp/define_error_definition_test.go @@ -0,0 +1,46 @@ +package owasp + +import ( + "testing" + + "github.com/daveshanley/vacuum/model" + "github.com/pb33f/libopenapi/utils" + "github.com/stretchr/testify/assert" +) + +func TestDefineErrorDefinition_GetSchema(t *testing.T) { + def := DefineErrorDefinition{} + assert.Equal(t, "define_error_definition", def.GetSchema().Name) +} + +func TestDefineErrorDefinition_RunRule(t *testing.T) { + def := DefineErrorDefinition{} + res := def.RunRule(nil, model.RuleFunctionContext{}) + assert.Len(t, res, 0) +} + +func TestDefineErrorDefinition_ErrorDefinitionMissing(t *testing.T) { + + yml := `openapi: "3.1.0" +info: + version: "1.0" +paths: + /: + get: + responses: + "422": + description: "classic validation fail" +` + + path := "$" + + nodes, _ := utils.FindNodes([]byte(yml), path) + + rule := buildOpenApiTestRuleAction(path, "define_error_definition", "", nil) + ctx := buildOpenApiTestContext(model.CastToRuleAction(rule.Then), nil) + + def := DefineErrorDefinition{} + res := def.RunRule(nodes, ctx) + + assert.Len(t, res, 1) +} diff --git a/functions/owasp/header_definition.go b/functions/owasp/header_definition.go new file mode 100644 index 00000000..6e2e7a49 --- /dev/null +++ b/functions/owasp/header_definition.go @@ -0,0 +1,124 @@ +package owasp + +import ( + "fmt" + "strconv" + "strings" + + "github.com/daveshanley/vacuum/model" + "github.com/pb33f/libopenapi/utils" + "golang.org/x/exp/slices" + "gopkg.in/yaml.v3" +) + +type message struct { + responseCode int + headersSets [][]string +} + +type HeaderDefinition struct { +} + +func (m message) String() string { + oout := "" + for _, headerSet := range m.headersSets { + oout += "{" + strings.Join(headerSet, ", ") + "}" + } + + return fmt.Sprintf(`response with code %d, must contain one of the defined 'headers' set: + {%s}`, m.responseCode, oout, + ) +} + +// GetSchema returns a model.RuleFunctionSchema defining the schema of the HeaderDefinition rule. +func (cd HeaderDefinition) GetSchema() model.RuleFunctionSchema { + return model.RuleFunctionSchema{Name: "header_definition"} +} + +// RunRule will execute the HeaderDefinition rule, based on supplied context and a supplied []*yaml.Node slice. +func (cd HeaderDefinition) RunRule(nodes []*yaml.Node, context model.RuleFunctionContext) []model.RuleFunctionResult { + if len(nodes) == 0 { + return nil + } + + var headers [][]string + methodsMap := utils.ExtractValueFromInterfaceMap("headers", context.Options) + if castedHeaders, ok := methodsMap.([][]string); ok { + headers = castedHeaders + } + + var responseCode = -1 + var results []model.RuleFunctionResult + for i, node := range nodes[0].Content { + if i%2 == 0 { + responseCode, _ = strconv.Atoi(node.Value) + } else if responseCode >= 200 && responseCode < 300 || responseCode >= 400 && responseCode < 500 { + result := cd.getResult(responseCode, node, context, headers) + results = append(results, result...) + responseCode = 0 + } + } + + return results +} + +func (cd HeaderDefinition) getResult(responseCode int, node *yaml.Node, context model.RuleFunctionContext, headersSets [][]string) []model.RuleFunctionResult { + var results []model.RuleFunctionResult + numberOfHeaders := 0 + + for i, headersNode := range node.Content { + if headersNode.Value == "headers" { + numberOfHeaders++ + if !(len(node.Content) > i+1) || !cd.validateNode(node.Content[i+1], headersSets) { + results = append(results, model.RuleFunctionResult{ + Message: message{responseCode: responseCode}.String(), + StartNode: headersNode, + EndNode: utils.FindLastChildNodeWithLevel(headersNode, 0), + Path: fmt.Sprintf("$.paths.responses.%d.headers", responseCode), + Rule: context.Rule, + }) + } + } + } + + // headers parameter not found + if numberOfHeaders == 0 { + results = append(results, model.RuleFunctionResult{ + Message: message{responseCode: responseCode, headersSets: headersSets}.String(), + StartNode: node, + EndNode: utils.FindLastChildNodeWithLevel(node, 0), + Path: fmt.Sprintf("$.paths.responses.%d", responseCode), + Rule: context.Rule, + }) + } + + return results +} + +// RunRule will execute the HeaderDefinition rule, based on supplied context and a supplied []*yaml.Node slice. +func (cd HeaderDefinition) validateNode(node *yaml.Node, headers [][]string) bool { + var nodeHeaders []string + for i, nodeHeader := range node.Content { + if i%2 == 0 { + nodeHeaders = append(nodeHeaders, nodeHeader.Value) + } + } + + for _, set := range headers { + if belong(set, nodeHeaders) { + return true + } + } + + return false +} + +func belong(set []string, nodeHeaders []string) bool { + for _, header := range set { + if !slices.Contains(nodeHeaders, header) { + return false + } + } + + return true +} diff --git a/functions/owasp/header_definition_test.go b/functions/owasp/header_definition_test.go new file mode 100644 index 00000000..cfc4d935 --- /dev/null +++ b/functions/owasp/header_definition_test.go @@ -0,0 +1,65 @@ +package owasp + +import ( + "testing" + + "github.com/daveshanley/vacuum/model" + "github.com/pb33f/libopenapi/utils" + "github.com/stretchr/testify/assert" +) + +func TestHeaderDefinition_GetSchema(t *testing.T) { + def := HeaderDefinition{} + assert.Equal(t, "header_definition", def.GetSchema().Name) +} + +func TestHeaderDefinition_RunRule(t *testing.T) { + def := HeaderDefinition{} + res := def.RunRule(nil, model.RuleFunctionContext{}) + assert.Len(t, res, 0) +} + +func TestHeaderDefinition_HeaderDefinitionMissing(t *testing.T) { + + yml := `paths: + /pizza/: + responses: + 400: + error + 200: + error + 299: + error + 499: + "Accept": + error + 461: + headers: + "Content-Type": + schema: + type: string + 450: + headers: + "Accept": + schema: + type: string + "Cache-Control": + schema: + type: string +` + + path := "$.paths..responses" + + nodes, _ := utils.FindNodes([]byte(yml), path) + + rule := buildOpenApiTestRuleAction(path, "header_definition", "", nil) + ctx := buildOpenApiTestContext(model.CastToRuleAction(rule.Then), map[string]interface{}{ + "headers": [][]string{{"Accept", "Cache-Control"}, {"Content-Type"}}, + }) + + def := HeaderDefinition{} + res := def.RunRule(nodes, ctx) + + assert.Len(t, res, 4) + +} diff --git a/functions/owasp/owasp_test.go b/functions/owasp/owasp_test.go new file mode 100644 index 00000000..6d5a15d7 --- /dev/null +++ b/functions/owasp/owasp_test.go @@ -0,0 +1,21 @@ +package owasp + +import "github.com/daveshanley/vacuum/model" + +func buildOpenApiTestRuleAction(given, function, field string, functionOptions interface{}) model.Rule { + return model.Rule{ + Given: given, + Then: &model.RuleAction{ + Field: field, + Function: function, + FunctionOptions: functionOptions, + }, + } +} + +func buildOpenApiTestContext(action *model.RuleAction, options map[string]interface{}) model.RuleFunctionContext { + return model.RuleFunctionContext{ + RuleAction: action, + Options: options, + } +} diff --git a/go.mod b/go.mod index 231de052..d61fb09d 100644 --- a/go.mod +++ b/go.mod @@ -17,6 +17,7 @@ require ( github.com/stretchr/testify v1.8.4 github.com/vmware-labs/yaml-jsonpath v0.3.2 go.uber.org/zap v1.24.0 + golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 gopkg.in/yaml.v3 v3.0.1 ) @@ -40,8 +41,8 @@ require ( github.com/rivo/uniseg v0.4.4 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - go.uber.org/atomic v1.9.0 // indirect - go.uber.org/multierr v1.8.0 // indirect + go.uber.org/atomic v1.7.0 // indirect + go.uber.org/multierr v1.6.0 // indirect golang.org/x/sync v0.1.0 // indirect golang.org/x/sys v0.8.0 // indirect golang.org/x/term v0.8.0 // indirect diff --git a/go.sum b/go.sum index 783ccd0e..63131551 100644 --- a/go.sum +++ b/go.sum @@ -106,12 +106,8 @@ github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1y github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= github.com/onsi/gomega v1.19.0 h1:4ieX6qQjPP/BfC3mpsAtIGGlxTWPeA3Inl/7DtXw1tw= github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro= -github.com/pb33f/libopenapi v0.8.2 h1:9oem/yteVXN/mFgC2SYvc4fPGVgZ+VSedY4ICoOykFE= -github.com/pb33f/libopenapi v0.8.2/go.mod h1:lvUmCtjgHUGVj6WzN3I5/CS9wkXtyN3Ykjh6ZZP5lrI= github.com/pb33f/libopenapi v0.9.0 h1:JVTt4alhUdgf4R4ByGCPsPowzu2vOZyKsuNqJq4DQFg= github.com/pb33f/libopenapi v0.9.0/go.mod h1:lvUmCtjgHUGVj6WzN3I5/CS9wkXtyN3Ykjh6ZZP5lrI= -github.com/pb33f/libopenapi-validator v0.0.7 h1:0ZtV2V28Fu69wIMccNeFHyusmubfoq4zy2uGqrjDHMk= -github.com/pb33f/libopenapi-validator v0.0.7/go.mod h1:uAF035zrQxpAdaoZuZZyy7eh7+Fjw3ovv4bOAPjt97U= github.com/pb33f/libopenapi-validator v0.0.8 h1:ydeoKZ8VMnrN+ORaXP64IQCqgbIMxv7EkhukbW72e0U= github.com/pb33f/libopenapi-validator v0.0.8/go.mod h1:uAF035zrQxpAdaoZuZZyy7eh7+Fjw3ovv4bOAPjt97U= github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= @@ -154,12 +150,11 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw= go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= -go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= -go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/goleak v1.1.11 h1:wy28qYRKZgnJTxGxvye5/wgWr1EKjmUDGYox5mGlRlI= -go.uber.org/multierr v1.8.0 h1:dg6GjLku4EH+249NNmoIciG9N/jURbDG+pFlTkhzIC8= -go.uber.org/multierr v1.8.0/go.mod h1:7EAYxJLBy9rStEaz58O2t4Uvip6FSURkq8/ppBp95ak= +go.uber.org/multierr v1.6.0 h1:y6IPFStTAIT5Ytl7/XYmHvzXQ7S3g/IeZW9hyZ5thw4= +go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= go.uber.org/zap v1.24.0 h1:FiJd5l1UOLj0wCgbSE0rwwXHzEdAZS6hiiSnxJN/D60= go.uber.org/zap v1.24.0/go.mod h1:2kMP+WWQ8aoFoedH3T2sq6iJ2yDWpHbP0f6MQbS9Gkg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= @@ -167,6 +162,7 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= diff --git a/motor/rule_applicator.go b/motor/rule_applicator.go index 957b1e64..0db38165 100644 --- a/motor/rule_applicator.go +++ b/motor/rule_applicator.go @@ -285,6 +285,10 @@ func runRule(ctx ruleContext) { givenPaths = append(givenPaths, x) } + if x, ok := ctx.rule.Given.([]string); ok { + givenPaths = x + } + if x, ok := ctx.rule.Given.([]interface{}); ok { for _, gpI := range x { if gp, ok := gpI.(string); ok { diff --git a/motor/rule_tests/owasp_tests/array_limit_test.go b/motor/rule_tests/owasp_tests/array_limit_test.go new file mode 100644 index 00000000..1d5cf08f --- /dev/null +++ b/motor/rule_tests/owasp_tests/array_limit_test.go @@ -0,0 +1,125 @@ +package tests + +import ( + "testing" + + "github.com/daveshanley/vacuum/model" + "github.com/daveshanley/vacuum/motor" + "github.com/daveshanley/vacuum/rulesets" + "github.com/stretchr/testify/assert" +) + +func TestRuleSet_OWASPArrayLimit_Success(t *testing.T) { + + tc := []struct { + name string + yml string + }{ + { + name: "valid case: oas2", + yml: `swagger: "2.0" +info: + version: "1.0" +definitions: + Foo: + type: array + maxItems: 99 +`, + }, + { + name: "valid case: oas3", + yml: `openapi: "3.1.0" +info: + version: "1.0" +components: + schemas: + Foo: + type: array + maxItems: 99 +`, + }, + { + name: "valid case: oas3.1", + yml: `openapi: "3.1.0" +info: + version: "1.0" +components: + schemas: + type: + type: string + maxLength: 99 + User: + type: object + properties: + type: + enum: ['user', 'admin'] +`, + }, + } + + for _, tt := range tc { + t.Run(tt.name, func(t *testing.T) { + rules := make(map[string]*model.Rule) + rules["owasp-array-limit"] = rulesets.GetOWASPArrayLimitRule() + + rs := &rulesets.RuleSet{ + Rules: rules, + } + + rse := &motor.RuleSetExecution{ + RuleSet: rs, + Spec: []byte(tt.yml), + } + results := motor.ApplyRulesToRuleSet(rse) + assert.Len(t, results.Results, 0) + }) + } +} + +func TestRuleSet_OWASPArrayLimit_Error(t *testing.T) { + + tc := []struct { + name string + yml string + }{ + { + name: "invalid case: oas2 missing maxItems", + yml: `swagger: "2.0" +info: + version: "1.0" +definitions: + Foo: + type: array +`, + }, + { + name: "invalid case: oas3 missing maxItems", + yml: `openapi: "3.0.0" +info: + version: "1.0" +components: + schemas: + Foo: + type: array +`, + }, + } + + for _, tt := range tc { + t.Run(tt.name, func(t *testing.T) { + rules := make(map[string]*model.Rule) + rules["owasp-array-limit"] = rulesets.GetOWASPArrayLimitRule() + + rs := &rulesets.RuleSet{ + Rules: rules, + } + + rse := &motor.RuleSetExecution{ + RuleSet: rs, + Spec: []byte(tt.yml), + } + results := motor.ApplyRulesToRuleSet(rse) + assert.NotEqual(t, len(results.Results), 0) + }) + } +} diff --git a/motor/rule_tests/owasp_tests/auth_insecure_schemes_test.go b/motor/rule_tests/owasp_tests/auth_insecure_schemes_test.go new file mode 100644 index 00000000..9c170963 --- /dev/null +++ b/motor/rule_tests/owasp_tests/auth_insecure_schemes_test.go @@ -0,0 +1,69 @@ +package tests + +import ( + "testing" + + "github.com/daveshanley/vacuum/model" + "github.com/daveshanley/vacuum/motor" + "github.com/daveshanley/vacuum/rulesets" + "github.com/stretchr/testify/assert" +) + +func TestRuleSet_OWASPAuthInsecureSchemes_Success(t *testing.T) { + + yml := `openapi: "3.1.0" +info: + version: "1.0" +components: + securitySchemes: + "bearer is ok": + type: "http" + scheme: "bearer"` + + t.Run("valid case", func(t *testing.T) { + rules := make(map[string]*model.Rule) + rules["owasp-auth-insecure-schemes"] = rulesets.GetOWASPAuthInsecureSchemesRule() + + rs := &rulesets.RuleSet{ + Rules: rules, + } + + rse := &motor.RuleSetExecution{ + RuleSet: rs, + Spec: []byte(yml), + } + results := motor.ApplyRulesToRuleSet(rse) + assert.Len(t, results.Results, 0) + }) +} + +func TestRuleSet_OWASPAuthInsecureSchemes_Error(t *testing.T) { + + yml := `openapi: "3.1.0" +info: + version: "1.0" +components: + securitySchemes: + "bad negotiate": + type: "http" + scheme: "negotiate" + "bad negotiate": + type: "http" + scheme: "oauth"` + + t.Run("invalid case", func(t *testing.T) { + rules := make(map[string]*model.Rule) + rules["owasp-auth-insecure-schemes"] = rulesets.GetOWASPAuthInsecureSchemesRule() + + rs := &rulesets.RuleSet{ + Rules: rules, + } + + rse := &motor.RuleSetExecution{ + RuleSet: rs, + Spec: []byte(yml), + } + results := motor.ApplyRulesToRuleSet(rse) + assert.Len(t, results.Results, 2) + }) +} diff --git a/motor/rule_tests/owasp_tests/contrained_additional_properties_test.go b/motor/rule_tests/owasp_tests/contrained_additional_properties_test.go new file mode 100644 index 00000000..01aa9d5a --- /dev/null +++ b/motor/rule_tests/owasp_tests/contrained_additional_properties_test.go @@ -0,0 +1,69 @@ +package tests + +import ( + "testing" + + "github.com/daveshanley/vacuum/model" + "github.com/daveshanley/vacuum/motor" + "github.com/daveshanley/vacuum/rulesets" + "github.com/stretchr/testify/assert" +) + +func TestRuleSet_OWASPConstrainedAdditionalProperties_Success(t *testing.T) { + + yml := `openapi: "3.0.0" +info: + version: "1.0" +components: + schemas: + Foo: + type: object + additionalProperties: indeterminate + maxProperties: 1 +` + + t.Run("valid case", func(t *testing.T) { + rules := make(map[string]*model.Rule) + rules["owasp-constrained-additionalProperties"] = rulesets.GetOWASPConstrainedAdditionalPropertiesRule() + + rs := &rulesets.RuleSet{ + Rules: rules, + } + + rse := &motor.RuleSetExecution{ + RuleSet: rs, + Spec: []byte(yml), + } + results := motor.ApplyRulesToRuleSet(rse) + assert.Len(t, results.Results, 0) + }) +} + +func TestRuleSet_OWASPConstrainedAdditionalProperties_Error(t *testing.T) { + + yml := `openapi: "3.0.0" +info: + version: "1.0" +components: + schemas: + Foo: + type: object + additionalProperties: indeterminate +` + + t.Run("invalid case", func(t *testing.T) { + rules := make(map[string]*model.Rule) + rules["owasp-constrained-additionalProperties"] = rulesets.GetOWASPConstrainedAdditionalPropertiesRule() + + rs := &rulesets.RuleSet{ + Rules: rules, + } + + rse := &motor.RuleSetExecution{ + RuleSet: rs, + Spec: []byte(yml), + } + results := motor.ApplyRulesToRuleSet(rse) + assert.Len(t, results.Results, 1) + }) +} diff --git a/motor/rule_tests/owasp_tests/define_error_responses_401_test.go b/motor/rule_tests/owasp_tests/define_error_responses_401_test.go new file mode 100644 index 00000000..b00bbe52 --- /dev/null +++ b/motor/rule_tests/owasp_tests/define_error_responses_401_test.go @@ -0,0 +1,102 @@ +package tests + +import ( + "testing" + + "github.com/daveshanley/vacuum/model" + "github.com/daveshanley/vacuum/motor" + "github.com/daveshanley/vacuum/rulesets" + "github.com/stretchr/testify/assert" +) + +func TestRuleSet_OWASPDefineErrorResponses401_Success(t *testing.T) { + + yml := `openapi: "3.1.0" +info: + version: "1.0" +paths: + /: + get: + responses: + 401: + description: "ok" + content: + "application/json": +` + + t.Run("valid: defines a 401 response with content", func(t *testing.T) { + rules := make(map[string]*model.Rule) + rules["owasp-define-error-responses-401"] = rulesets.GetOWASPDefineErrorResponses401Rule() + + rs := &rulesets.RuleSet{ + Rules: rules, + } + + rse := &motor.RuleSetExecution{ + RuleSet: rs, + Spec: []byte(yml), + } + results := motor.ApplyRulesToRuleSet(rse) + assert.Len(t, results.Results, 0) + }) +} + +func TestRuleSet_OWASPDefineErrorResponses401_Error(t *testing.T) { + + tc := []struct { + name string + yml string + n int + }{ + { + name: "invalid: 401 is not defined at all", + n: 2, + yml: `openapi: "3.1.0" +info: + version: "1.0" +paths: + /: + get: + responses: + 200: + description: "ok" + content: + "application/problem+json": +`, + }, + { + name: "invalid: 401 exists but content is missing", + n: 1, + yml: `openapi: "3.1.0" +info: + version: "1.0" +paths: + /: + get: + responses: + 401: + description: "ok" + invalid-content: + "application/problem+json" +`, + }, + } + + for _, tt := range tc { + t.Run(tt.name, func(t *testing.T) { + rules := make(map[string]*model.Rule) + rules["owasp-define-error-responses-401"] = rulesets.GetOWASPDefineErrorResponses401Rule() + + rs := &rulesets.RuleSet{ + Rules: rules, + } + + rse := &motor.RuleSetExecution{ + RuleSet: rs, + Spec: []byte(tt.yml), + } + results := motor.ApplyRulesToRuleSet(rse) + assert.Len(t, results.Results, tt.n) + }) + } +} diff --git a/motor/rule_tests/owasp_tests/define_error_responses_429_test.go b/motor/rule_tests/owasp_tests/define_error_responses_429_test.go new file mode 100644 index 00000000..031331a6 --- /dev/null +++ b/motor/rule_tests/owasp_tests/define_error_responses_429_test.go @@ -0,0 +1,105 @@ +package tests + +import ( + "testing" + + "github.com/daveshanley/vacuum/model" + "github.com/daveshanley/vacuum/motor" + "github.com/daveshanley/vacuum/rulesets" + "github.com/stretchr/testify/assert" +) + +func TestRuleSet_OWASPDefineErrorResponses429_Success(t *testing.T) { + + yml := `openapi: "3.1.0" +info: + version: "1.0" +paths: + /: + get: + responses: + 429: + description: "ok" + content: + "application/json": +` + + t.Run("valid: defines a 429 response with content", func(t *testing.T) { + rules := make(map[string]*model.Rule) + rules["owasp-define-error-responses-429"] = rulesets.GetOWASPDefineErrorResponses429Rule() + + rs := &rulesets.RuleSet{ + Rules: rules, + } + + rse := &motor.RuleSetExecution{ + RuleSet: rs, + Spec: []byte(yml), + } + results := motor.ApplyRulesToRuleSet(rse) + assert.Len(t, results.Results, 0) + }) +} + +func TestRuleSet_OWASPDefineErrorResponses429_Error(t *testing.T) { + + tc := []struct { + name string + yml string + n int + }{ + { + name: "invalid: 429 is not defined at all", + n: 2, + yml: `openapi: "3.1.0" +info: + version: "1.0" +paths: + /: + get: + responses: + 200: + description: "ok" + content: + "application/problem+json": +`, + }, + { + name: "invalid: 429 exists but content is missing", + n: 1, + yml: `openapi: "3.1.0" +info: + version: "1.0" +paths: + /: + get: + responses: + 429: + description: "ok" + invalid-content: + "application/problem+json" +`, + }, + } + + for _, tt := range tc { + t.Run(tt.name, func(t *testing.T) { + if tt.n == 1 { + return + } + rules := make(map[string]*model.Rule) + rules["owasp-define-error-responses-429"] = rulesets.GetOWASPDefineErrorResponses429Rule() + + rs := &rulesets.RuleSet{ + Rules: rules, + } + + rse := &motor.RuleSetExecution{ + RuleSet: rs, + Spec: []byte(tt.yml), + } + results := motor.ApplyRulesToRuleSet(rse) + assert.Len(t, results.Results, tt.n) + }) + } +} diff --git a/motor/rule_tests/owasp_tests/define_error_responses_500_test.go b/motor/rule_tests/owasp_tests/define_error_responses_500_test.go new file mode 100644 index 00000000..a9b306b0 --- /dev/null +++ b/motor/rule_tests/owasp_tests/define_error_responses_500_test.go @@ -0,0 +1,102 @@ +package tests + +import ( + "testing" + + "github.com/daveshanley/vacuum/model" + "github.com/daveshanley/vacuum/motor" + "github.com/daveshanley/vacuum/rulesets" + "github.com/stretchr/testify/assert" +) + +func TestRuleSet_OWASPDefineErrorResponses500_Success(t *testing.T) { + + yml := `openapi: "3.1.0" +info: + version: "1.0" +paths: + /: + get: + responses: + 500: + description: "ok" + content: + "application/json": +` + + t.Run("valid: defines a 500 response with content", func(t *testing.T) { + rules := make(map[string]*model.Rule) + rules["owasp-define-error-responses-500"] = rulesets.GetOWASPDefineErrorResponses500Rule() + + rs := &rulesets.RuleSet{ + Rules: rules, + } + + rse := &motor.RuleSetExecution{ + RuleSet: rs, + Spec: []byte(yml), + } + results := motor.ApplyRulesToRuleSet(rse) + assert.Len(t, results.Results, 0) + }) +} + +func TestRuleSet_OWASPDefineErrorResponses500_Error(t *testing.T) { + + tc := []struct { + name string + yml string + n int + }{ + { + name: "invalid: 500 is not defined at all", + n: 2, + yml: `openapi: "3.1.0" +info: + version: "1.0" +paths: + /: + get: + responses: + 200: + description: "ok" + content: + "application/problem+json": +`, + }, + { + name: "invalid: 500 exists but content is missing", + n: 1, + yml: `openapi: "3.1.0" +info: + version: "1.0" +paths: + /: + get: + responses: + 500: + description: "ok" + invalid-content: + "application/problem+json" +`, + }, + } + + for _, tt := range tc { + t.Run(tt.name, func(t *testing.T) { + rules := make(map[string]*model.Rule) + rules["owasp-define-error-responses-500"] = rulesets.GetOWASPDefineErrorResponses500Rule() + + rs := &rulesets.RuleSet{ + Rules: rules, + } + + rse := &motor.RuleSetExecution{ + RuleSet: rs, + Spec: []byte(tt.yml), + } + results := motor.ApplyRulesToRuleSet(rse) + assert.Len(t, results.Results, tt.n) + }) + } +} diff --git a/motor/rule_tests/owasp_tests/define_error_validation_test.go b/motor/rule_tests/owasp_tests/define_error_validation_test.go new file mode 100644 index 00000000..9a10ee97 --- /dev/null +++ b/motor/rule_tests/owasp_tests/define_error_validation_test.go @@ -0,0 +1,109 @@ +package tests + +import ( + "testing" + + "github.com/daveshanley/vacuum/model" + "github.com/daveshanley/vacuum/motor" + "github.com/daveshanley/vacuum/rulesets" + "github.com/stretchr/testify/assert" +) + +func TestRuleSet_OWASPDefineErrorValidation_Success(t *testing.T) { + + tc := []struct { + name string + yml string + }{ + { + name: "valid case: 400", + yml: `openapi: "3.1.0" +info: + version: "1.0" +paths: + /: + get: + responses: + "400": + description: "classic validation fail"`, + }, + { + name: "valid case: 422", + yml: `openapi: "3.1.0" +info: + version: "1.0" +paths: + /: + get: + responses: + "422": + description: "classic validation fail"`, + }, + { + name: "valid case: 4XX", + yml: `openapi: "3.1.0" +info: + version: "1.0" +paths: + /: + get: + responses: + "4XX": + description: "classic validation fail"`, + }, + } + + for _, tt := range tc { + t.Run(tt.name, func(t *testing.T) { + rules := make(map[string]*model.Rule) + rules["owasp-define-error-validation"] = rulesets.GetOWASPDefineErrorValidationRule() + + rs := &rulesets.RuleSet{ + Rules: rules, + } + + rse := &motor.RuleSetExecution{ + RuleSet: rs, + Spec: []byte(tt.yml), + } + results := motor.ApplyRulesToRuleSet(rse) + assert.Len(t, results.Results, 0) + }) + } +} + +func TestRuleSet_OWASPDefineErrorValidation_Error(t *testing.T) { + + yml := `openapi: "3.1.0" +info: + version: "1.0" +paths: + /: + get: + responses: + 200: + description: "ok" + content: + "application/json": + 401: + description: "ok" + content: + "application/json": +` + + t.Run("invalid case", func(t *testing.T) { + rules := make(map[string]*model.Rule) + rules["owasp-define-error-validation"] = rulesets.GetOWASPDefineErrorValidationRule() + + rs := &rulesets.RuleSet{ + Rules: rules, + } + + rse := &motor.RuleSetExecution{ + RuleSet: rs, + Spec: []byte(yml), + } + results := motor.ApplyRulesToRuleSet(rse) + assert.Len(t, results.Results, 1) + }) +} diff --git a/motor/rule_tests/owasp_tests/integer_format_test.go b/motor/rule_tests/owasp_tests/integer_format_test.go new file mode 100644 index 00000000..9a1ed550 --- /dev/null +++ b/motor/rule_tests/owasp_tests/integer_format_test.go @@ -0,0 +1,111 @@ +package tests + +import ( + "testing" + + "github.com/daveshanley/vacuum/model" + "github.com/daveshanley/vacuum/motor" + "github.com/daveshanley/vacuum/rulesets" + "github.com/stretchr/testify/assert" +) + +func TestRuleSet_OWASPIntegerFormat_Success(t *testing.T) { + + tc := []struct { + name string + yml string + }{ + { + name: "valid case: format - int32", + yml: `openapi: "3.1.0" +info: + version: "1.0" +components: + schemas: + Foo: + type: integer + format: int32 +`, + }, + { + name: "valid case: format - int64", + yml: `openapi: "3.1.0" +info: + version: "1.0" +components: + schemas: + Foo: + type: integer + format: int64 +`, + }, + { + name: "valid case: format - whatever", + yml: `openapi: "3.1.0" +info: + version: "1.0" +components: + schemas: + Foo: + type: integer + format: whatever +`, + }, + } + + for _, tt := range tc { + t.Run(tt.name, func(t *testing.T) { + rules := make(map[string]*model.Rule) + rules["owasp-integer-format"] = rulesets.GetOWASPIntegerFormatRule() + + rs := &rulesets.RuleSet{ + Rules: rules, + } + + rse := &motor.RuleSetExecution{ + RuleSet: rs, + Spec: []byte(tt.yml), + } + results := motor.ApplyRulesToRuleSet(rse) + assert.Len(t, results.Results, 0) + }) + } +} + +func TestRuleSet_OWASPIntegerFormat_Error(t *testing.T) { + + tc := []struct { + name string + yml string + }{ + { + name: "invalid case: no format", + yml: `openapi: "3.1.0" +info: + version: "1.0" +components: + schemas: + Foo: + type: integer +`, + }, + } + + for _, tt := range tc { + t.Run(tt.name, func(t *testing.T) { + rules := make(map[string]*model.Rule) + rules["owasp-integer-format"] = rulesets.GetOWASPIntegerFormatRule() + + rs := &rulesets.RuleSet{ + Rules: rules, + } + + rse := &motor.RuleSetExecution{ + RuleSet: rs, + Spec: []byte(tt.yml), + } + results := motor.ApplyRulesToRuleSet(rse) + assert.NotEqual(t, len(results.Results), 0) + }) + } +} diff --git a/motor/rule_tests/owasp_tests/integer_limit_legacy_test.go b/motor/rule_tests/owasp_tests/integer_limit_legacy_test.go new file mode 100644 index 00000000..10178125 --- /dev/null +++ b/motor/rule_tests/owasp_tests/integer_limit_legacy_test.go @@ -0,0 +1,133 @@ +package tests + +import ( + "testing" + + "github.com/daveshanley/vacuum/model" + "github.com/daveshanley/vacuum/motor" + "github.com/daveshanley/vacuum/rulesets" + "github.com/stretchr/testify/assert" +) + +func TestRuleSet_OWASPIntegerLimitLegacy_Success(t *testing.T) { + + tc := []struct { + name string + yml string + }{ + { + name: "valid case: oas2", + yml: `swagger: "2.0" +info: + version: "1.0" +definitions: + Foo: + type: integer + minimum: 1 + maximum: 99 +`, + }, + { + name: "valid case: oas3.0", + yml: `openapi: "3.0.0" +info: + version: "1.0" +components: + schemas: + Foo: + type: integer + minimum: 1 + maximum: 99 +`, + }, + } + + for _, tt := range tc { + t.Run(tt.name, func(t *testing.T) { + rules := make(map[string]*model.Rule) + rules["owasp-integer-limit-legacy"] = rulesets.GetOWASPIntegerLimitLegacyRule() + + rs := &rulesets.RuleSet{ + Rules: rules, + } + + rse := &motor.RuleSetExecution{ + RuleSet: rs, + Spec: []byte(tt.yml), + } + results := motor.ApplyRulesToRuleSet(rse) + assert.Len(t, results.Results, 0) + }) + } +} + +func TestRuleSet_OWASPIntegerLimitLegacy_Error(t *testing.T) { + + tc := []struct { + name string + yml string + }{ + { + name: "invalid case: oas2 missing maximum", + yml: `swagger: "2.0" +info: + version: "1.0" +definitions: + Foo: + type: integer +`, + }, + { + name: "invalid case: oas3.0 missing maximum", + yml: `openapi: "3.0.0" +info: + version: "1.0" +components: + schemas: + Foo: + type: integer +`, + }, + { + name: "invalid case: oas2 has maximum but missing minimum", + yml: `swagger: "2.0" +info: + version: "1.0" +definitions: + Foo: + type: integer + maximum: 99 +`, + }, + { + name: "invalid case: oas3.0 has maximum but missing minimum", + yml: `openapi: "3.0.0" +info: + version: "1.0" +components: + schemas: + Foo: + type: integer + maximum: 99 +`, + }, + } + + for _, tt := range tc { + t.Run(tt.name, func(t *testing.T) { + rules := make(map[string]*model.Rule) + rules["owasp-integer-limit-legacy"] = rulesets.GetOWASPIntegerLimitLegacyRule() + + rs := &rulesets.RuleSet{ + Rules: rules, + } + + rse := &motor.RuleSetExecution{ + RuleSet: rs, + Spec: []byte(tt.yml), + } + results := motor.ApplyRulesToRuleSet(rse) + assert.NotEqual(t, len(results.Results), 0) + }) + } +} diff --git a/motor/rule_tests/owasp_tests/integer_limit_test.go b/motor/rule_tests/owasp_tests/integer_limit_test.go new file mode 100644 index 00000000..c81e5055 --- /dev/null +++ b/motor/rule_tests/owasp_tests/integer_limit_test.go @@ -0,0 +1,179 @@ +package tests + +import ( + "testing" + + "github.com/daveshanley/vacuum/model" + "github.com/daveshanley/vacuum/motor" + "github.com/daveshanley/vacuum/rulesets" + "github.com/stretchr/testify/assert" +) + +func TestRuleSet_OWASPIntegerLimit_Success(t *testing.T) { + + tc := []struct { + name string + yml string + }{ + { + name: "valid case: minimum and maximum", + yml: `openapi: "3.1.0" +info: + version: "1.0" +components: + schemas: + Foo: + type: integer + minimum: 1 + maximum: 99 +`, + }, + { + name: "valid case: exclusiveMinimum and exclusiveMaximum", + yml: `openapi: "3.1.0" +info: + version: "1.0" +components: + schemas: + Foo: + type: integer + exclusiveMinimum: 1 + exclusiveMaximum: 99 +`, + }, + { + name: "valid case: minimum and exclusiveMaximum", + yml: `openapi: "3.1.0" +info: + version: "1.0" +components: + schemas: + Foo: + type: integer + minimum: 1 + exclusiveMaximum: 99 +`, + }, + { + name: "valid case: exclusiveMinimum and maximum", + yml: `openapi: "3.1.0" +info: + version: "1.0" +components: + schemas: + Foo: + type: integer + exclusiveMinimum: 1 + maximum: 99 +`, + }, + } + + for _, tt := range tc { + t.Run(tt.name, func(t *testing.T) { + rules := make(map[string]*model.Rule) + rules["owasp-integer-limit"] = rulesets.GetOWASPIntegerLimitRule() + + rs := &rulesets.RuleSet{ + Rules: rules, + } + + rse := &motor.RuleSetExecution{ + RuleSet: rs, + Spec: []byte(tt.yml), + } + results := motor.ApplyRulesToRuleSet(rse) + assert.Len(t, results.Results, 0) + }) + } +} + +func TestRuleSet_OWASPIntegerLimit_Error(t *testing.T) { + + tc := []struct { + name string + yml string + }{ + { + name: "invalid case: only maximum", + yml: `openapi: "3.1.0" +info: + version: "1.0" +components: + schemas: + Foo: + type: integer + exclusiveMinimum: 99 + minimum: 99 +`, + }, + { + name: "invalid case: only exclusiveMaximum", + yml: `openapi: "3.1.0" +info: + version: "1.0" +components: + schemas: + Foo: + type: integer + exclusiveMaximum: 99 +`, + }, + { + name: "invalid case: only maximum", + yml: `openapi: "3.1.0" +info: + version: "1.0" +components: + schemas: + Foo: + type: integer + maximum: 99 +`, + }, + { + name: "invalid case: only exclusiveMinimum", + yml: `openapi: "3.1.0" +info: + version: "1.0" +components: + schemas: + Foo: + type: integer + exclusiveMinimum: 1 +`, + }, + { + name: "invalid case: both minimums and an exclusiveMaximum", + yml: `openapi: "3.1.0" +info: + version: "1.0" +components: + schemas: + Foo: + type: integer + minimum: 1 + exclusiveMinimum: 1 + exclusiveMaximum: 4 +`, + }, + } + + for _, tt := range tc { + t.Run(tt.name, func(t *testing.T) { + rules := make(map[string]*model.Rule) + rules["owasp-integer-limit"] = rulesets.GetOWASPIntegerLimitRule() + + rs := &rulesets.RuleSet{ + Rules: rules, + } + + rse := &motor.RuleSetExecution{ + RuleSet: rs, + Spec: []byte(tt.yml), + } + results := motor.ApplyRulesToRuleSet(rse) + assert.NotEqual(t, len(results.Results), 0) + }) + } +} diff --git a/motor/rule_tests/owasp_tests/jwt_best_practices_test.go b/motor/rule_tests/owasp_tests/jwt_best_practices_test.go new file mode 100644 index 00000000..b5bd529e --- /dev/null +++ b/motor/rule_tests/owasp_tests/jwt_best_practices_test.go @@ -0,0 +1,74 @@ +package tests + +import ( + "testing" + + "github.com/daveshanley/vacuum/model" + "github.com/daveshanley/vacuum/motor" + "github.com/daveshanley/vacuum/rulesets" + "github.com/stretchr/testify/assert" +) + +func TestRuleSet_OWASPJWTBestPractices_Success(t *testing.T) { + + yml := `openapi: "3.1.0" +info: + version: "1.0" +components: + securitySchemes: + "bad oauth2": + type: "http" + description: "These JWTs use RFC8725." + "bad bearer jwt": + type: "http" + bearerFormat: "jwt" + description: "These JWTs use RFC8725."` + + t.Run("valid case", func(t *testing.T) { + rules := make(map[string]*model.Rule) + rules["owasp-jwt-best-practices"] = rulesets.GetOWASPJWTBestPracticesRule() + + rs := &rulesets.RuleSet{ + Rules: rules, + } + + rse := &motor.RuleSetExecution{ + RuleSet: rs, + Spec: []byte(yml), + } + results := motor.ApplyRulesToRuleSet(rse) + assert.Len(t, results.Results, 0) + }) +} + +func TestRuleSet_OWASPJWTBestPractices_Error(t *testing.T) { + + yml := `openapi: "3.1.0" +info: + version: "1.0" +components: + securitySchemes: + "bad oauth2": + type: "oauth2" + description: "No way of knowing if these JWTs are following best practices." + "bad bearer jwt": + type: "http" + bearerFormat: "jwt" + description: "No way of knowing if these JWTs are following best practices."` + + t.Run("invalid case", func(t *testing.T) { + rules := make(map[string]*model.Rule) + rules["owasp-jwt-best-practices"] = rulesets.GetOWASPJWTBestPracticesRule() + + rs := &rulesets.RuleSet{ + Rules: rules, + } + + rse := &motor.RuleSetExecution{ + RuleSet: rs, + Spec: []byte(yml), + } + results := motor.ApplyRulesToRuleSet(rse) + assert.Len(t, results.Results, 2) + }) +} diff --git a/motor/rule_tests/owasp_tests/no_additional_properties_test.go b/motor/rule_tests/owasp_tests/no_additional_properties_test.go new file mode 100644 index 00000000..05b778e8 --- /dev/null +++ b/motor/rule_tests/owasp_tests/no_additional_properties_test.go @@ -0,0 +1,128 @@ +package tests + +import ( + "testing" + + "github.com/daveshanley/vacuum/model" + "github.com/daveshanley/vacuum/motor" + "github.com/daveshanley/vacuum/rulesets" + "github.com/stretchr/testify/assert" +) + +func TestRuleSet_OWASPNoAdditionalProperties_Success(t *testing.T) { + + tc := []struct { + name string + yml string + }{ + { + name: "valid case: oas2 does not allow additionalProperties by default so dont worry about it", + yml: `swagger: "2.0" +info: + version: "1.0" +definitions: + Foo: + type: object + additionalProperties: false +`, + }, + { + name: "valid case: oas3", + yml: `openapi: "3.0.0" +info: + version: "1.0" +components: + schemas: + Foo: + type: object + additionalProperties: false +`, + }, + { + name: "valid case: no additionalProperties defined", + yml: `openapi: "3.0.0" +info: + version: "1.0" +components: + schemas: + Foo: + type: object +`, + }, + } + + for _, tt := range tc { + t.Run(tt.name, func(t *testing.T) { + rules := make(map[string]*model.Rule) + rules["owasp-no-additionalProperties"] = rulesets.GetOWASPNoAdditionalPropertiesRule() + + rs := &rulesets.RuleSet{ + Rules: rules, + } + + rse := &motor.RuleSetExecution{ + RuleSet: rs, + Spec: []byte(tt.yml), + } + results := motor.ApplyRulesToRuleSet(rse) + assert.Len(t, results.Results, 0) + }) + } +} + +func TestRuleSet_OWASPNoAdditionalProperties_Error(t *testing.T) { + + tc := []struct { + name string + yml string + }{ + { + name: "invalid case: additionalProperties set to true (oas3)", + yml: `openapi: "3.0.0" +info: + version: "1.0" +components: + schemas: + Foo: + type: object + additionalProperties: true +`, + }, + { + name: "invalid case: additionalProperties set to an object (oas3)", + yml: `openapi: "3.0.0" +info: + version: "1.0" +components: + schemas: + Foo: + type: object + additionalProperties: + type: object + properties: + code: + type: integer + text: + type: string +`, + }, + } + + for _, tt := range tc { + t.Run(tt.name, func(t *testing.T) { + rules := make(map[string]*model.Rule) + rules["owasp-no-additionalProperties"] = rulesets.GetOWASPNoAdditionalPropertiesRule() + + rs := &rulesets.RuleSet{ + Rules: rules, + } + + rse := &motor.RuleSetExecution{ + RuleSet: rs, + Spec: []byte(tt.yml), + } + results := motor.ApplyRulesToRuleSet(rse) + assert.Len(t, results.Results, 1) + }) + } +} diff --git a/motor/rule_tests/owasp_tests/no_api_keys_in_url_test.go b/motor/rule_tests/owasp_tests/no_api_keys_in_url_test.go new file mode 100644 index 00000000..6352beef --- /dev/null +++ b/motor/rule_tests/owasp_tests/no_api_keys_in_url_test.go @@ -0,0 +1,69 @@ +package tests + +import ( + "testing" + + "github.com/daveshanley/vacuum/model" + "github.com/daveshanley/vacuum/motor" + "github.com/daveshanley/vacuum/rulesets" + "github.com/stretchr/testify/assert" +) + +func TestRuleSet_OWASPNoAPIKeysInURL_Success(t *testing.T) { + + yml := `openapi: "3.1.0" +info: + version: "1.0" +components: + securitySchemes: + "API Key in URL": + type: "APIKey" + in: "header"` + + t.Run("valid case", func(t *testing.T) { + rules := make(map[string]*model.Rule) + rules["owasp-no-api-keys-in-url"] = rulesets.GetOWASPNoAPIKeysInURLRule() + + rs := &rulesets.RuleSet{ + Rules: rules, + } + + rse := &motor.RuleSetExecution{ + RuleSet: rs, + Spec: []byte(yml), + } + results := motor.ApplyRulesToRuleSet(rse) + assert.Len(t, results.Results, 0) + }) +} + +func TestRuleSet_OWASPNoAPIKeysInURL_Error(t *testing.T) { + + yml := `openapi: "3.1.0" +info: + version: "1.0" +components: + securitySchemes: + "API Key in Query": + type: apiKey + in: query + "API Key in Path": + type: apiKey + in: path` + + t.Run("invalid case", func(t *testing.T) { + rules := make(map[string]*model.Rule) + rules["owasp-no-api-keys-in-url"] = rulesets.GetOWASPNoAPIKeysInURLRule() + + rs := &rulesets.RuleSet{ + Rules: rules, + } + + rse := &motor.RuleSetExecution{ + RuleSet: rs, + Spec: []byte(yml), + } + results := motor.ApplyRulesToRuleSet(rse) + assert.Len(t, results.Results, 2) + }) +} diff --git a/motor/rule_tests/owasp_tests/no_credentials_in_url_test.go b/motor/rule_tests/owasp_tests/no_credentials_in_url_test.go new file mode 100644 index 00000000..3be0c728 --- /dev/null +++ b/motor/rule_tests/owasp_tests/no_credentials_in_url_test.go @@ -0,0 +1,95 @@ +package tests + +import ( + "testing" + + "github.com/daveshanley/vacuum/model" + "github.com/daveshanley/vacuum/motor" + "github.com/daveshanley/vacuum/rulesets" + "github.com/stretchr/testify/assert" +) + +func TestRuleSet_OWASPNoCredentialsInURL_Success(t *testing.T) { + + yml := `openapi: "3.1.0" +paths: + /foo/{id}/: + get: + description: "get" + parameters: + - name: id + in: path + required: true + - name: filter + in: query + required: true` + + t.Run("invalid case", func(t *testing.T) { + rules := make(map[string]*model.Rule) + rules["owasp-no-credentials-in-url"] = rulesets.GetOWASPNoCredentialsInURLRule() + + rs := &rulesets.RuleSet{ + Rules: rules, + } + + rse := &motor.RuleSetExecution{ + RuleSet: rs, + Spec: []byte(yml), + } + results := motor.ApplyRulesToRuleSet(rse) + assert.Len(t, results.Results, 0) + }) +} + +func TestRuleSet_OWASPNoCredentialsInURL_Error(t *testing.T) { + + yml := `openapi: "3.1.0" +paths: + /foo/{id}/: + get: + description: "get" + parameters: + - name: client_secret + in: query + required: true + - name: token + in: query + required: true + - name: refresh_token + in: query + required: true + - name: id_token + in: query + required: true + - name: password + in: query + required: true + - name: secret + in: query + required: true + - name: apikey + in: query + required: true + - name: apikey + in: path + required: true + - name: API-KEY + in: query + required: true` + + t.Run("valid case", func(t *testing.T) { + rules := make(map[string]*model.Rule) + rules["owasp-no-credentials-in-url"] = rulesets.GetOWASPNoCredentialsInURLRule() + + rs := &rulesets.RuleSet{ + Rules: rules, + } + + rse := &motor.RuleSetExecution{ + RuleSet: rs, + Spec: []byte(yml), + } + results := motor.ApplyRulesToRuleSet(rse) + assert.Len(t, results.Results, 9) + }) +} diff --git a/motor/rule_tests/owasp_tests/no_http_basic_test.go b/motor/rule_tests/owasp_tests/no_http_basic_test.go new file mode 100644 index 00000000..d4f66564 --- /dev/null +++ b/motor/rule_tests/owasp_tests/no_http_basic_test.go @@ -0,0 +1,69 @@ +package tests + +import ( + "testing" + + "github.com/daveshanley/vacuum/model" + "github.com/daveshanley/vacuum/motor" + "github.com/daveshanley/vacuum/rulesets" + "github.com/stretchr/testify/assert" +) + +func TestRuleSet_OWASPNoHttpBasic_Success(t *testing.T) { + + yml := `openapi: "3.1.0" +info: + version: "1.0" +components: + securitySchemes: + "anything-else": + type: "http" + scheme: "bearer"` + + t.Run("valid case", func(t *testing.T) { + rules := make(map[string]*model.Rule) + rules["owasp-no-http-basic"] = rulesets.GetOWASPNoHttpBasicRule() + + rs := &rulesets.RuleSet{ + Rules: rules, + } + + rse := &motor.RuleSetExecution{ + RuleSet: rs, + Spec: []byte(yml), + } + results := motor.ApplyRulesToRuleSet(rse) + assert.Len(t, results.Results, 0) + }) +} + +func TestRuleSet_OWASPNoHttpBasic_Error(t *testing.T) { + + yml := `openapi: "3.1.0" +info: + version: "1.0" +components: + securitySchemes: + "bad negotiate": + type: "http" + scheme: "negotiate" + "please-hack-me": + type: "http" + scheme: basic` + + t.Run("invalid case", func(t *testing.T) { + rules := make(map[string]*model.Rule) + rules["owasp-no-http-basic"] = rulesets.GetOWASPNoHttpBasicRule() + + rs := &rulesets.RuleSet{ + Rules: rules, + } + + rse := &motor.RuleSetExecution{ + RuleSet: rs, + Spec: []byte(yml), + } + results := motor.ApplyRulesToRuleSet(rse) + assert.Len(t, results.Results, 1) + }) +} diff --git a/motor/rule_tests/owasp_tests/no_numeric_ids_test.go b/motor/rule_tests/owasp_tests/no_numeric_ids_test.go new file mode 100644 index 00000000..b90bb306 --- /dev/null +++ b/motor/rule_tests/owasp_tests/no_numeric_ids_test.go @@ -0,0 +1,88 @@ +package tests + +import ( + "testing" + + "github.com/daveshanley/vacuum/model" + "github.com/daveshanley/vacuum/motor" + "github.com/daveshanley/vacuum/rulesets" + "github.com/stretchr/testify/assert" +) + +func TestRuleSet_OWASPNoNumericIDs_Success(t *testing.T) { + + yml := `openapi: "3.1.0" +paths: + /foo/{id}/: + get: + description: "get" + parameters: + - name: id + in: path + schema: + type: string + format: uuid` + + t.Run("valid case", func(t *testing.T) { + rules := make(map[string]*model.Rule) + rules["owasp-no-numeric-ids"] = rulesets.GetOWASPNoNumericIDsRule() + + rs := &rulesets.RuleSet{ + Rules: rules, + } + + rse := &motor.RuleSetExecution{ + RuleSet: rs, + Spec: []byte(yml), + } + results := motor.ApplyRulesToRuleSet(rse) + assert.Len(t, results.Results, 0) + }) +} + +func TestRuleSet_OWASPNoNumericIDs_Error(t *testing.T) { + + yml := `openapi: "3.1.0" +paths: + /foo/{id}/: + get: + description: "get" + parameters: + - name: id + in: path + schema: + type: integer + - name: notanid + in: path + schema: + type: integer + - name: underscore_id + in: path + schema: + type: integer + - name: hyphen-id + in: path + schema: + type: integer + format: int32 + - name: camelId + in: path + schema: + type: integer` + + t.Run("invalid case", func(t *testing.T) { + rules := make(map[string]*model.Rule) + rules["owasp-no-numeric-ids"] = rulesets.GetOWASPNoNumericIDsRule() + + rs := &rulesets.RuleSet{ + Rules: rules, + } + + rse := &motor.RuleSetExecution{ + RuleSet: rs, + Spec: []byte(yml), + } + results := motor.ApplyRulesToRuleSet(rse) + assert.Len(t, results.Results, 5) + }) +} diff --git a/motor/rule_tests/owasp_tests/protection_global_safe_test.go b/motor/rule_tests/owasp_tests/protection_global_safe_test.go new file mode 100644 index 00000000..710c3941 --- /dev/null +++ b/motor/rule_tests/owasp_tests/protection_global_safe_test.go @@ -0,0 +1,99 @@ +package tests + +import ( + "testing" + + "github.com/daveshanley/vacuum/model" + "github.com/daveshanley/vacuum/motor" + "github.com/daveshanley/vacuum/rulesets" + "github.com/stretchr/testify/assert" +) + +func TestRuleSet_OWASPProtectionGlobalSafe_Success(t *testing.T) { + + yml := `openapi: 3.0.1 +info: + version: "1.2.3" + title: "securitySchemes" +paths: + /security-ko-missing: + put: + responses: {} + post: + security: [] + /security-ok-put: + put: + security: + - BasicAuth: [] + responses: {} + /security-ok-get: + get: + security: + - {} + responses: {} + head: + security: + - {} + - BasicAuth: [] + /security-ko-info: + post: + security: + - {} + - BasicAuth: [] +components: + securitySchemes: + BasicAuth: + type: http + scheme: basic` + + t.Run("valid case", func(t *testing.T) { + rules := make(map[string]*model.Rule) + rules["owasp-protection-global-safe"] = rulesets.GetOWASPProtectionGlobalSafeRule() + + rs := &rulesets.RuleSet{ + Rules: rules, + } + + rse := &motor.RuleSetExecution{ + RuleSet: rs, + Spec: []byte(yml), + } + results := motor.ApplyRulesToRuleSet(rse) + assert.Len(t, results.Results, 0) + }) +} + +func TestRuleSet_OWASPProtectionGlobalSafe_Error(t *testing.T) { + + yml := `openapi: 3.0.1 +info: + version: "1.2.3" + title: "securitySchemes" +paths: + /security-ko-get: + get: + responses: {} + head: + security: [] +components: + securitySchemes: + BasicAuth: + type: http + scheme: basic` + + t.Run("valid case", func(t *testing.T) { + rules := make(map[string]*model.Rule) + rules["owasp-protection-global-safe"] = rulesets.GetOWASPProtectionGlobalSafeRule() + + rs := &rulesets.RuleSet{ + Rules: rules, + } + + rse := &motor.RuleSetExecution{ + RuleSet: rs, + Spec: []byte(yml), + } + results := motor.ApplyRulesToRuleSet(rse) + assert.Len(t, results.Results, 2) + }) +} diff --git a/motor/rule_tests/owasp_tests/protection_global_unsafe_strict_test.go b/motor/rule_tests/owasp_tests/protection_global_unsafe_strict_test.go new file mode 100644 index 00000000..101ccafb --- /dev/null +++ b/motor/rule_tests/owasp_tests/protection_global_unsafe_strict_test.go @@ -0,0 +1,93 @@ +package tests + +import ( + "testing" + + "github.com/daveshanley/vacuum/model" + "github.com/daveshanley/vacuum/motor" + "github.com/daveshanley/vacuum/rulesets" + "github.com/stretchr/testify/assert" +) + +func TestRuleSet_OWASPProtectionGlobalUnsafeStrict_Success(t *testing.T) { + + yml := `openapi: 3.0.1 +info: + version: "1.2.3" + title: "securitySchemes" +security: + - BasicAuth: [] +paths: + /security-gloabl-ok-put: + put: + responses: {} + /security-ok-put: + put: + security: + - BasicAuth: [] + responses: {} +components: + securitySchemes: + BasicAuth: + type: http + scheme: basic` + + t.Run("valid case", func(t *testing.T) { + rules := make(map[string]*model.Rule) + rules["owasp-protection-global-unsafe-strict"] = rulesets.GetOWASPProtectionGlobalUnsafeStrictRule() + + rs := &rulesets.RuleSet{ + Rules: rules, + } + + rse := &motor.RuleSetExecution{ + RuleSet: rs, + Spec: []byte(yml), + } + results := motor.ApplyRulesToRuleSet(rse) + assert.Len(t, results.Results, 0) + }) +} + +func TestRuleSet_OWASPProtectionGlobalUnsafeStrict_Error(t *testing.T) { + + yml := `openapi: 3.0.1 +info: + version: "1.2.3" + title: "securitySchemes" +security: + - BasicAuth: [] +paths: + /security-ko-patch-noauth: + patch: + security: + - {} + responses: {} + /security-ko-post-noauth: + patch: + security: + - BasicAuth: [] + - {} + responses: {} +components: + securitySchemes: + BasicAuth: + type: http + scheme: basic` + + t.Run("valid case", func(t *testing.T) { + rules := make(map[string]*model.Rule) + rules["owasp-protection-global-unsafe-strict"] = rulesets.GetOWASPProtectionGlobalUnsafeStrictRule() + + rs := &rulesets.RuleSet{ + Rules: rules, + } + + rse := &motor.RuleSetExecution{ + RuleSet: rs, + Spec: []byte(yml), + } + results := motor.ApplyRulesToRuleSet(rse) + assert.Len(t, results.Results, 2) + }) +} diff --git a/motor/rule_tests/owasp_tests/protection_global_unsafe_test.go b/motor/rule_tests/owasp_tests/protection_global_unsafe_test.go new file mode 100644 index 00000000..a8de03e3 --- /dev/null +++ b/motor/rule_tests/owasp_tests/protection_global_unsafe_test.go @@ -0,0 +1,99 @@ +package tests + +import ( + "testing" + + "github.com/daveshanley/vacuum/model" + "github.com/daveshanley/vacuum/motor" + "github.com/daveshanley/vacuum/rulesets" + "github.com/stretchr/testify/assert" +) + +func TestRuleSet_OWASPProtectionGlobalUnsafe_Success(t *testing.T) { + + yml := `openapi: 3.0.1 +info: + version: "1.2.3" + title: "securitySchemes" +paths: + /security-ok-put: + put: + security: + - BasicAuth: [] + responses: {} + /security-ok-get: + get: + responses: {} + head: + security: [] + /security-ok-get: + get: + security: + - {} + responses: {} + head: + security: + - {} + - BasicAuth: [] +components: + securitySchemes: + BasicAuth: + type: http + scheme: basic` + + t.Run("valid case", func(t *testing.T) { + rules := make(map[string]*model.Rule) + rules["owasp-protection-global-unsafe"] = rulesets.GetOWASPProtectionGlobalUnsafeRule() + + rs := &rulesets.RuleSet{ + Rules: rules, + } + + rse := &motor.RuleSetExecution{ + RuleSet: rs, + Spec: []byte(yml), + } + results := motor.ApplyRulesToRuleSet(rse) + assert.Len(t, results.Results, 0) + }) +} + +func TestRuleSet_OWASPProtectionGlobalUnsafe_Error(t *testing.T) { + + yml := `openapi: 3.0.1 +info: + version: "1.2.3" + title: "securitySchemes" +paths: + /security-ko-missing: + put: + responses: {} + post: + security: [] + /security-ko-info: + post: + security: + - {} + - BasicAuth: [] +components: + securitySchemes: + BasicAuth: + type: http + scheme: basic` + + t.Run("valid case", func(t *testing.T) { + rules := make(map[string]*model.Rule) + rules["owasp-protection-global-unsafe"] = rulesets.GetOWASPProtectionGlobalUnsafeRule() + + rs := &rulesets.RuleSet{ + Rules: rules, + } + + rse := &motor.RuleSetExecution{ + RuleSet: rs, + Spec: []byte(yml), + } + results := motor.ApplyRulesToRuleSet(rse) + assert.Len(t, results.Results, 2) + }) +} diff --git a/motor/rule_tests/owasp_tests/rate_limit_retry_after_test.go b/motor/rule_tests/owasp_tests/rate_limit_retry_after_test.go new file mode 100644 index 00000000..6f655ae2 --- /dev/null +++ b/motor/rule_tests/owasp_tests/rate_limit_retry_after_test.go @@ -0,0 +1,83 @@ +package tests + +import ( + "testing" + + "github.com/daveshanley/vacuum/model" + "github.com/daveshanley/vacuum/motor" + "github.com/daveshanley/vacuum/rulesets" + "github.com/stretchr/testify/assert" +) + +func TestRuleSet_OWASPRateLimitRetryAfter_Success(t *testing.T) { + + yml := `openapi: "3.1.0" +info: + version: "1.0" +paths: + /: + get: + responses: + "429": + description: "ok" + headers: + "Retry-After": + description: "standard retry header" + schema: + type: string +` + + t.Run("valid case", func(t *testing.T) { + rules := make(map[string]*model.Rule) + rules["owasp-rate-limit-retry-after"] = rulesets.GetOWASPRateLimitRetryAfterRule() + + rs := &rulesets.RuleSet{ + Rules: rules, + } + + rse := &motor.RuleSetExecution{ + RuleSet: rs, + Spec: []byte(yml), + } + results := motor.ApplyRulesToRuleSet(rse) + assert.Len(t, results.Results, 0) + }) +} + +func TestRuleSet_OWASPRateLimitRetryAfter_Error(t *testing.T) { + + yml := `openapi: "3.1.0" +info: + version: "1.0" +paths: + /: + get: + responses: + 429: + description: "ok" + headers: + 200: + description: "ok" + headers: + "Retry-After": + description: "standard retry header" + schema: + type: string +` + + t.Run("invalid case", func(t *testing.T) { + rules := make(map[string]*model.Rule) + rules["owasp-rate-limit-retry-after"] = rulesets.GetOWASPRateLimitRetryAfterRule() + + rs := &rulesets.RuleSet{ + Rules: rules, + } + + rse := &motor.RuleSetExecution{ + RuleSet: rs, + Spec: []byte(yml), + } + results := motor.ApplyRulesToRuleSet(rse) + assert.Len(t, results.Results, 1) + }) +} diff --git a/motor/rule_tests/owasp_tests/rate_limit_test.go b/motor/rule_tests/owasp_tests/rate_limit_test.go new file mode 100644 index 00000000..7bcf826f --- /dev/null +++ b/motor/rule_tests/owasp_tests/rate_limit_test.go @@ -0,0 +1,153 @@ +package tests + +import ( + "testing" + + "github.com/daveshanley/vacuum/model" + "github.com/daveshanley/vacuum/motor" + "github.com/daveshanley/vacuum/rulesets" + "github.com/stretchr/testify/assert" +) + +func TestRuleSet_OWASPRateLimit_Success(t *testing.T) { + + tc := []struct { + name string + yml string + }{ + { + name: "valid use of IETF Draft HTTP RateLimit Headers", + yml: `openapi: "3.1.0" +info: + version: "1.0" +paths: + /: + get: + responses: + "201": + description: "ok" + headers: + "X-RateLimit-Limit": + schema: + type: string + "X-RateLimit-Reset": + schema: + type: string`, + }, + { + name: "valid use of Twitter-style Rate Limit Headers", + yml: `openapi: "3.1.0" +info: + version: "1.0" +paths: + /: + get: + responses: + "201": + description: "ok" + headers: + "X-Rate-Limit-Limit": + schema: + type: string`, + }, + { + name: "valid use of GitHub-style Rate Limit Headers", + yml: `openapi: "3.1.0" +info: + version: "1.0" +paths: + /: + get: + responses: + "201": + description: "ok" + headers: + "X-RateLimit-Limit": + schema: + type: string`, + }, + } + + for _, tt := range tc { + t.Run(tt.name, func(t *testing.T) { + rules := make(map[string]*model.Rule) + rules["owasp-rate-limit"] = rulesets.GetOWASPRateLimitRule() + + rs := &rulesets.RuleSet{ + Rules: rules, + } + + rse := &motor.RuleSetExecution{ + RuleSet: rs, + Spec: []byte(tt.yml), + } + results := motor.ApplyRulesToRuleSet(rse) + assert.Len(t, results.Results, 0) + }) + } +} + +func TestRuleSet_OWASPRateLimit_Error(t *testing.T) { + + tc := []struct { + name string + n int + yml string + }{ + { + name: "invalid case: no limit headers set", + n: 1, + yml: `openapi: "3.1.0" +info: + version: "1.0" +paths: + /: + get: + description: "get" + responses: + "201": + description: "ok" + headers: + "SomethingElse": + schema: + type: string +`, + }, + { + name: "invalid case: no rate limit headers set", + n: 1, + yml: `openapi: "3.1.0" +info: + version: "1.0" +paths: + /: + get: + responses: + "201": + description: "ok" + headers: + "Wrong-RateLimit-Limit": + schema: + type: string +`, + }, + } + + for _, tt := range tc { + t.Run(tt.name, func(t *testing.T) { + rules := make(map[string]*model.Rule) + rules["owasp-rate-limit"] = rulesets.GetOWASPRateLimitRule() + + rs := &rulesets.RuleSet{ + Rules: rules, + } + + rse := &motor.RuleSetExecution{ + RuleSet: rs, + Spec: []byte(tt.yml), + } + results := motor.ApplyRulesToRuleSet(rse) + assert.Len(t, results.Results, tt.n) + }) + } +} diff --git a/motor/rule_tests/owasp_tests/security_hosts_https_oas_2_test.go b/motor/rule_tests/owasp_tests/security_hosts_https_oas_2_test.go new file mode 100644 index 00000000..51b128ce --- /dev/null +++ b/motor/rule_tests/owasp_tests/security_hosts_https_oas_2_test.go @@ -0,0 +1,126 @@ +package tests + +import ( + "testing" + + "github.com/daveshanley/vacuum/model" + "github.com/daveshanley/vacuum/motor" + "github.com/daveshanley/vacuum/rulesets" + "github.com/stretchr/testify/assert" +) + +func TestRuleSet_OWASPSecurityHostsHttpsOAS2_Success(t *testing.T) { + + tc := []struct { + name string + yml string + }{ + { + name: "valid case", + yml: `swagger: "2.0" +info: + version: "1.0" +definitions: + Foo: + type: integer +paths: + "/" +host: + - example.com +schemes: + - https +`, + }, + } + + for _, tt := range tc { + t.Run(tt.name, func(t *testing.T) { + rules := make(map[string]*model.Rule) + rules["owasp-security-hosts-https-oas2"] = rulesets.GetOWASPSecurityHostsHttpsOAS2Rule() + + rs := &rulesets.RuleSet{ + Rules: rules, + } + + rse := &motor.RuleSetExecution{ + RuleSet: rs, + Spec: []byte(tt.yml), + } + results := motor.ApplyRulesToRuleSet(rse) + assert.Len(t, results.Results, 0) + }) + } +} + +func TestRuleSet_OWASPSecurityHostsHttpsOAS2_Error(t *testing.T) { + + tc := []struct { + name string + yml string + }{ + { + name: "an invalid server.url using http", + yml: `swagger: "2.0" +info: + version: "1.0" +definitions: + Foo: + type: integer +paths: + "/" +host: + - example.com +schemes: + - http +`, + }, + { + name: "an invalid server.url using http and https", + yml: `swagger: "2.0" +info: + version: "1.0" +definitions: + Foo: + type: integer +paths: + "/" +host: + - example.com +schemes: [https, http] +`, + }, + { + name: "an invalid server using ftp", + yml: `swagger: "2.0" +info: + version: "1.0" +definitions: + Foo: + type: integer +paths: + "/" +host: + - example.com +schemes: [ftp] +`, + }, + } + + for _, tt := range tc { + t.Run(tt.name, func(t *testing.T) { + rules := make(map[string]*model.Rule) + rules["owasp-security-hosts-https-oas2"] = rulesets.GetOWASPSecurityHostsHttpsOAS2Rule() + + rs := &rulesets.RuleSet{ + Rules: rules, + } + + rse := &motor.RuleSetExecution{ + RuleSet: rs, + Spec: []byte(tt.yml), + } + results := motor.ApplyRulesToRuleSet(rse) + assert.Len(t, results.Results, 1) + }) + } +} diff --git a/motor/rule_tests/owasp_tests/security_hosts_https_oas_3_test.go b/motor/rule_tests/owasp_tests/security_hosts_https_oas_3_test.go new file mode 100644 index 00000000..adb294bc --- /dev/null +++ b/motor/rule_tests/owasp_tests/security_hosts_https_oas_3_test.go @@ -0,0 +1,97 @@ +package tests + +import ( + "testing" + + "github.com/daveshanley/vacuum/model" + "github.com/daveshanley/vacuum/motor" + "github.com/daveshanley/vacuum/rulesets" + "github.com/stretchr/testify/assert" +) + +func TestRuleSet_OWASPSecurityHostsHttpsOAS3_Success(t *testing.T) { + + tc := []struct { + name string + yml string + }{ + { + name: "valid case", + yml: `openapi: "3.1.0" +info: + version: "1.0" +paths: + /: +servers: + - url: https://api.example.com/ +`, + }, + } + + for _, tt := range tc { + t.Run(tt.name, func(t *testing.T) { + rules := make(map[string]*model.Rule) + rules["owasp-security-hosts-https-oas3"] = rulesets.GetOWASPSecurityHostsHttpsOAS3Rule() + + rs := &rulesets.RuleSet{ + Rules: rules, + } + + rse := &motor.RuleSetExecution{ + RuleSet: rs, + Spec: []byte(tt.yml), + } + results := motor.ApplyRulesToRuleSet(rse) + assert.Len(t, results.Results, 0) + }) + } +} + +func TestRuleSet_OWASPSecurityHostsHttpsOAS3_Error(t *testing.T) { + + tc := []struct { + name string + yml string + }{ + { + name: "an invalid server.url using http", + yml: `openapi: "3.1.0" +info: + version: "1.0" +paths: + /: +servers: + - url: http://api.example.com/ +`, + }, + { + name: "an invalid server using ftp", + yml: `openapi: "3.1.0" +info: + version: "1.0" +paths: + /: +servers: + - url: ftp://api.example.com/ +`, + }, + } + + for _, tt := range tc { + t.Run(tt.name, func(t *testing.T) { + rules := make(map[string]*model.Rule) + rules["owasp-security-hosts-https-oas3"] = rulesets.GetOWASPSecurityHostsHttpsOAS3Rule() + + rs := &rulesets.RuleSet{ + Rules: rules, + } + + rse := &motor.RuleSetExecution{ + RuleSet: rs, + Spec: []byte(tt.yml), + } + results := motor.ApplyRulesToRuleSet(rse) + assert.Len(t, results.Results, 1) + }) + } +} diff --git a/motor/rule_tests/owasp_tests/string_limit_test.go b/motor/rule_tests/owasp_tests/string_limit_test.go new file mode 100644 index 00000000..6a78a020 --- /dev/null +++ b/motor/rule_tests/owasp_tests/string_limit_test.go @@ -0,0 +1,161 @@ +package tests + +import ( + "testing" + + "github.com/daveshanley/vacuum/model" + "github.com/daveshanley/vacuum/motor" + "github.com/daveshanley/vacuum/rulesets" + "github.com/stretchr/testify/assert" +) + +func TestRuleSet_OWASPStringLimit_Success(t *testing.T) { + + tc := []struct { + name string + yml string + }{ + { + name: "valid case: oas2", + yml: `swagger: "2.0" +info: + version: "1.0" +definitions: + Foo: + type: string + maxLength: 99`, + }, + { + name: "valid case: oas3.0", + yml: `openapi: "3.0.0" +info: + version: "1.0" +components: + schemas: + Foo: + type: string + maxLength: 99`, + }, + { + name: "valid case: oas3.1", + yml: `openapi: "3.1.0" +info: + version: "1.0" +components: + schemas: + Foo: + type: ["null", "string"] + maxLength: 99`, + }, + { + name: "valid case: oas3.0", + yml: `openapi: "3.0.0" +info: + version: "1.0" +components: + schemas: + Foo: + type: "string" + enum: [a, b, c]`, + }, + { + name: "valid case: oas3.1", + yml: `openapi: "3.1.0" +info: + version: "1.0" +components: + schemas: + Foo: + type: "string" + const: "constant"`, + }, + { + name: "valid case: pattern and maxLength, oas3.1", + yml: `openapi: "3.1.0" +info: + version: "1.0" +components: + schemas: + Foo: + type: "string" + format: "hex" + pattern: "^[0-9a-fA-F]+$" + maxLength: 10`, + }, + } + + for _, tt := range tc { + t.Run(tt.name, func(t *testing.T) { + rules := make(map[string]*model.Rule) + rules["owasp-string-limit"] = rulesets.GetOWASPStringLimitRule() + + rs := &rulesets.RuleSet{ + Rules: rules, + } + + rse := &motor.RuleSetExecution{ + RuleSet: rs, + Spec: []byte(tt.yml), + } + results := motor.ApplyRulesToRuleSet(rse) + assert.Len(t, results.Results, 0) + }) + } +} + +func TestRuleSet_OWASPStringLimit_Error(t *testing.T) { + + tc := []struct { + name string + n int + yml string + }{ + { + name: "invalid case: oas2 missing maxLength", + yml: `swagger: "2.0" +info: + version: "1.0" +definitions: + Foo: + type: string`, + }, + { + name: "invalid case: oas3.0 missing maxLength", + yml: `openapi: "3.0.0" +info: + version: "1.0" +components: + schemas: + Foo: + type: string`, + }, + { + name: "invalid case: oas3.1 missing maxLength", + yml: `openapi: "3.1.0" +info: + version: "1.0" +components: + schemas: + Foo: + type: [null, string]`, + }, + } + + for _, tt := range tc { + t.Run(tt.name, func(t *testing.T) { + rules := make(map[string]*model.Rule) + rules["owasp-string-limit"] = rulesets.GetOWASPStringLimitRule() + + rs := &rulesets.RuleSet{ + Rules: rules, + } + + rse := &motor.RuleSetExecution{ + RuleSet: rs, + Spec: []byte(tt.yml), + } + results := motor.ApplyRulesToRuleSet(rse) + assert.NotEqual(t, len(results.Results), 0) + }) + } +} diff --git a/motor/rule_tests/owasp_tests/string_restricted_test.go b/motor/rule_tests/owasp_tests/string_restricted_test.go new file mode 100644 index 00000000..f9d14dd4 --- /dev/null +++ b/motor/rule_tests/owasp_tests/string_restricted_test.go @@ -0,0 +1,175 @@ +package tests + +import ( + "testing" + + "github.com/daveshanley/vacuum/model" + "github.com/daveshanley/vacuum/motor" + "github.com/daveshanley/vacuum/rulesets" + "github.com/stretchr/testify/assert" +) + +func TestRuleSet_OWASPStringRestricted_Success(t *testing.T) { + + tc := []struct { + name string + yml string + }{ + { + name: "valid case: format (oas2)", + yml: `swagger: "2.0" +info: + version: "1.0" +definitions: + Foo: + type: string + format: email`, + }, + { + name: "valid case: format (oas2)", + yml: `swagger: "2.0" +info: + version: "1.0" +definitions: + Foo: + type: string + pattern: "/^foo/"`, + }, + { + name: "valid case: format (oas3)", + yml: `openapi: "3.1.0" +info: + version: "1.0" +components: + schemas: + Foo: + type: string + format: email`, + }, + { + name: "valid case: pattern (oas3)", + yml: `openapi: "3.1.0" +info: + version: "1.0" +components: + schemas: + Foo: + type: string + pattern: "/^foo/"`, + }, + { + name: "valid case: format (oas3.1)", + yml: `openapi: "3.1.0" +info: + version: "1.0" +components: + schemas: + Foo: + type: ["null", "string"] + format: email`, + }, + { + name: "valid case: pattern (oas3.1)", + yml: `openapi: "3.1.0" +info: + version: "1.0" +components: + schemas: + Foo: + type: ["null", "string"] + pattern: "/^foo/"`, + }, + { + name: "valid case: enum (oas3)", + yml: `openapi: "3.0.0" +info: + version: "1.0" +components: + schemas: + Foo: + type: string + enum: ["a", "b", "c"]`, + }, + { + name: "valid case: format + pattern (oas3.1)", + yml: `openapi: "3.1.0" +info: + version: "1.0" +components: + schemas: + Foo: + type: string + format: hex + pattern: "^[0-9a-fA-F]+$" + maxLength: 16`, + }, + } + + for _, tt := range tc { + t.Run(tt.name, func(t *testing.T) { + rules := make(map[string]*model.Rule) + rules["owasp-string-restricted"] = rulesets.GetOWASPStringRestrictedRule() + + rs := &rulesets.RuleSet{ + Rules: rules, + } + + rse := &motor.RuleSetExecution{ + RuleSet: rs, + Spec: []byte(tt.yml), + } + results := motor.ApplyRulesToRuleSet(rse) + assert.Len(t, results.Results, 0) + }) + } +} + +func TestRuleSet_OWASPStringRestricted_Error(t *testing.T) { + + tc := []struct { + name string + yml string + }{ + { + name: "invalid case: neither format or pattern (oas2)", + yml: `swagger: "2.0" +info: + version: "1.0" +definitions: + Foo: + type: string +`, + }, + { + name: "invalid case: neither format or pattern (oas3)", + yml: `openapi: "3.1.0" +info: + version: "1.0" +components: + schemas: + Foo: + type: [null, string] + Bar: + type: string +`, + }, + } + + for _, tt := range tc { + t.Run(tt.name, func(t *testing.T) { + rules := make(map[string]*model.Rule) + rules["owasp-string-restricted"] = rulesets.GetOWASPStringRestrictedRule() + + rs := &rulesets.RuleSet{ + Rules: rules, + } + + rse := &motor.RuleSetExecution{ + RuleSet: rs, + Spec: []byte(tt.yml), + } + results := motor.ApplyRulesToRuleSet(rse) + assert.NotEqual(t, len(results.Results), 0) + }) + } +} diff --git a/rulesets/owasp_ruleset_functions.go b/rulesets/owasp_ruleset_functions.go new file mode 100644 index 00000000..9def397e --- /dev/null +++ b/rulesets/owasp_ruleset_functions.go @@ -0,0 +1,939 @@ +package rulesets + +import ( + "regexp" + + "github.com/daveshanley/vacuum/model" + "github.com/daveshanley/vacuum/parser" +) + +// rules taken from https://github.com/stoplightio/spectral-owasp-ruleset/blob/main/src/ruleset.ts + +func GetOWASPNoNumericIDsRule() *model.Rule { + + // create a schema to match against. + opts := make(map[string]interface{}) + yml := `type: object +not: + properties: + type: + pattern: integer +properties: + format: + enum: + - uuid` + + jsonSchema, _ := parser.ConvertYAMLIntoJSONSchema(yml, nil) + opts["schema"] = jsonSchema + opts["forceValidation"] = true // this will be picked up by the schema function to force validation. + + return &model.Rule{ + Name: "Use random IDs that cannot be guessed. UUIDs are preferred", + Id: OwaspNoNumericIDs, + Formats: model.AllFormats, + Description: "OWASP API1:2019 - Use random IDs that cannot be guessed. UUIDs are preferred", + Given: `$.paths..parameters[*][?(@.name == "id" || @.name =~ /(_id|Id|-id)$/)))]`, + Resolved: false, + RuleCategory: model.RuleCategories[model.CategoryInfo], + Recommended: true, + Type: Validation, + Severity: model.SeverityError, + Then: model.RuleAction{ + Field: "schema", + Function: "schema", + FunctionOptions: opts, + }, + HowToFix: owaspNoNumericIDsFix, + } +} + +func GetOWASPNoHttpBasicRule() *model.Rule { + + // create a schema to match against. + opts := make(map[string]interface{}) + opts["notMatch"] = "basic" + comp, _ := regexp.Compile(opts["notMatch"].(string)) + + return &model.Rule{ + Name: "Security scheme uses HTTP Basic. Use a more secure authentication method, like OAuth 2.0", + Id: OwaspNoHttpBasic, + Formats: model.AllFormats, + Description: "Basic authentication credentials transported over network are more susceptible to interception than other forms of authentication, and as they are not encrypted it means passwords and tokens are more easily leaked", + Given: `$.components.securitySchemes[*]`, + Resolved: false, + RuleCategory: model.RuleCategories[model.CategoryInfo], + Recommended: true, + Type: Validation, + Severity: model.SeverityError, + Then: model.RuleAction{ + Field: "scheme", + Function: "pattern", + FunctionOptions: opts, + }, + PrecompiledPattern: comp, + HowToFix: owaspNoHttpBasicFix, + } +} + +func GetOWASPNoAPIKeysInURLRule() *model.Rule { + + // create a schema to match against. + opts := make(map[string]interface{}) + opts["notMatch"] = "^(path|query)$" + comp, _ := regexp.Compile(opts["notMatch"].(string)) + + return &model.Rule{ + Name: "ApiKey passed in URL: {{error}}", + Id: OwaspNoAPIKeysInURL, + Formats: model.OAS3AllFormat, + Description: "API Keys are (usually opaque) strings that are passed in headers, cookies or query parameters to access APIs. Those keys can be eavesdropped, especially when they are stored in cookies or passed as URL parameters.```\nsecurity:\n- ApiKey: []\npaths:\n /books: {}\n /users: {}\nsecuritySchemes:\n ApiKey:\n type: apiKey\n in: cookie\n name: X-Api-Key\n```", + Given: `$..securitySchemes[*][?(@.type=="apiKey")].in`, + Resolved: false, + RuleCategory: model.RuleCategories[model.CategoryInfo], + Recommended: true, + Type: Validation, + Severity: model.SeverityError, + Then: model.RuleAction{ + Function: "pattern", + FunctionOptions: opts, + }, + PrecompiledPattern: comp, + HowToFix: owaspNoAPIKeysInURLFix, + } +} + +func GetOWASPNoCredentialsInURLRule() *model.Rule { + + // create a schema to match against. + opts := make(map[string]interface{}) + opts["notMatch"] = `(?i)^.*(client_?secret|token|access_?token|refresh_?token|id_?token|password|secret|api-?key).*$` + comp, _ := regexp.Compile(opts["notMatch"].(string)) + + return &model.Rule{ + Name: "Security credentials detected in path parameter: {{value}}", + Id: OwaspNoCredentialsInURL, + Formats: model.OAS3AllFormat, + Description: "URL parameters MUST NOT contain credentials such as API key, password, or secret.", + Given: `$..parameters[*][?(@.in =~ /(query|path)/)].name`, + Resolved: false, + RuleCategory: model.RuleCategories[model.CategoryInfo], + Recommended: true, + Type: Validation, + Severity: model.SeverityError, + Then: model.RuleAction{ + Function: "pattern", + FunctionOptions: opts, + }, + PrecompiledPattern: comp, + HowToFix: owaspNoCredentialsInURLFix, + } +} + +func GetOWASPAuthInsecureSchemesRule() *model.Rule { + + // create a schema to match against. + opts := make(map[string]interface{}) + opts["notMatch"] = `^(negotiate|oauth)$` + comp, _ := regexp.Compile(opts["notMatch"].(string)) + + return &model.Rule{ + Name: "Authentication scheme is considered outdated or insecure: {{value}}", + Id: OwaspAuthInsecureSchemes, + Formats: model.OAS3AllFormat, + Description: "There are many HTTP authorization schemes but some of them are now considered insecure, such as negotiating authentication using specifications like NTLM or OAuth v1", + Given: `$..securitySchemes[*][?(@.type=="http")].scheme`, + Resolved: false, + RuleCategory: model.RuleCategories[model.CategoryInfo], + Recommended: true, + Type: Validation, + Severity: model.SeverityError, + Then: model.RuleAction{ + Function: "pattern", + FunctionOptions: opts, + }, + PrecompiledPattern: comp, + HowToFix: owaspAuthInsecureSchemesFix, + } +} + +func GetOWASPJWTBestPracticesRule() *model.Rule { + + // create a schema to match against. + opts := make(map[string]interface{}) + opts["match"] = `.*RFC8725.*` + comp, _ := regexp.Compile(opts["match"].(string)) + + return &model.Rule{ + Name: "Security schemes using JWTs must explicitly declare support for RFC8725 in the description", + Id: OwaspJWTBestPractices, + Description: "", + Given: []string{ + `$..securitySchemes[*][?(@.type=="oauth2")]`, + `$..securitySchemes[*][?(@.bearerFormat=="jwt" || @.bearerFormat=="JWT")]`, + }, + Resolved: false, + RuleCategory: model.RuleCategories[model.CategoryInfo], + Recommended: true, + Type: Validation, + Severity: model.SeverityError, + Then: model.RuleAction{ + Field: "description", + Function: "pattern", + FunctionOptions: opts, + }, + PrecompiledPattern: comp, + HowToFix: owaspJWTBestPracticesFix, + } +} + +// https://github.com/italia/api-oas-checker/blob/master/security/security.yml +func GetOWASPProtectionGlobalUnsafeRule() *model.Rule { + return &model.Rule{ + Name: "This operation is not protected by any security scheme", + Id: OwaspProtectionGlobalUnsafe, + Description: "Your API should be protected by a `security` rule either at global or operation level.", + Given: `$`, + Resolved: false, + RuleCategory: model.RuleCategories[model.CategoryInfo], + Recommended: true, + Type: Validation, + Severity: model.SeverityError, + Then: model.RuleAction{ + Function: "owaspCheckSecurity", + FunctionOptions: map[string]interface{}{ + "schemesPath": []string{"securitySchemes"}, + "nullable": true, + "methods": []string{"post", "put", "patch", "delete"}, + }, + }, + HowToFix: owaspProtectionFix, + } +} + +// https://github.com/italia/api-oas-checker/blob/master/security/security.yml +func GetOWASPProtectionGlobalUnsafeStrictRule() *model.Rule { + return &model.Rule{ + Name: "This operation is not protected by any security scheme", + Id: OwaspProtectionGlobalUnsafeStrict, + Description: "Check if the operation is protected at operation level. Otherwise, check the global `#/security` property", + Given: `$`, + Resolved: false, + RuleCategory: model.RuleCategories[model.CategoryInfo], + Recommended: true, + Type: Validation, + Severity: model.SeverityInfo, + Then: model.RuleAction{ + Function: "owaspCheckSecurity", + FunctionOptions: map[string]interface{}{ + "schemesPath": []string{"securitySchemes"}, + "nullable": false, + "methods": []string{"post", "put", "patch", "delete"}, + }, + }, + HowToFix: owaspProtectionFix, + } +} + +// https://github.com/italia/api-oas-checker/blob/master/security/security.yml +func GetOWASPProtectionGlobalSafeRule() *model.Rule { + return &model.Rule{ + Name: "This operation is not protected by any security scheme", + Id: OwaspProtectionGlobalSafe, + Description: "Check if the operation is protected at operation level. Otherwise, check the global `#/security` property", + Given: `$`, + Resolved: false, + RuleCategory: model.RuleCategories[model.CategoryInfo], + Recommended: true, + Type: Validation, + Severity: model.SeverityInfo, + Then: model.RuleAction{ + Function: "owaspCheckSecurity", + FunctionOptions: map[string]interface{}{ + "schemesPath": []string{"securitySchemes"}, + "nullable": true, + "methods": []string{"get", "head"}, + }, + }, + HowToFix: owaspProtectionFix, + } +} + +func GetOWASPDefineErrorValidationRule() *model.Rule { + return &model.Rule{ + Name: "Missing error response of either 400, 422 or 4XX", + Id: OwaspDefineErrorValidation, + Description: "Carefully define schemas for all the API responses, including either 400, 422 or 4XX responses which describe errors caused by invalid requests", + Given: `$.paths..responses`, + Resolved: false, + RuleCategory: model.RuleCategories[model.CategoryInfo], + Recommended: true, + Type: Validation, + Severity: model.SeverityWarn, + Then: model.RuleAction{ + Function: "owaspDefineErrorDefinition", + }, + HowToFix: owaspDefineErrorValidationFix, + } +} + +func GetOWASPDefineErrorResponses401Rule() *model.Rule { + + // create a schema to match against. + opts := make(map[string]interface{}) + yml := `type: object +required: + - content` + + jsonSchema, _ := parser.ConvertYAMLIntoJSONSchema(yml, nil) + opts["schema"] = jsonSchema + opts["forceValidation"] = true // this will be picked up by the schema function to force validation. + + return &model.Rule{ + Name: "Operation is missing {{property}}", + Id: OwaspDefineErrorResponses401, + Description: "OWASP API Security recommends defining schemas for all responses, even errors: 401 response error code", + Given: `$.paths..responses`, + Resolved: false, + Formats: model.AllFormats, + RuleCategory: model.RuleCategories[model.CategoryInfo], + Recommended: true, + Type: Validation, + Severity: model.SeverityWarn, + Then: []model.RuleAction{ + { + Field: "401", + Function: "defined", + }, + { + Field: "401", + Function: "schema", + FunctionOptions: opts, + }, + }, + HowToFix: owaspDefineErrorResponses401Fix, + } +} + +func GetOWASPDefineErrorResponses500Rule() *model.Rule { + + opts := make(map[string]interface{}) + yml := `type: object +required: + - content` + + jsonSchema, _ := parser.ConvertYAMLIntoJSONSchema(yml, nil) + opts["schema"] = jsonSchema + opts["forceValidation"] = true // this will be picked up by the schema function to force validation. + + return &model.Rule{ + Name: "Operation is missing {{property}}", + Id: OwaspDefineErrorResponses500, + Description: "OWASP API Security recommends defining schemas for all responses, even errors: 500 response error code", + Given: `$.paths..responses`, + Resolved: false, + RuleCategory: model.RuleCategories[model.CategoryInfo], + Recommended: true, + Type: Validation, + Severity: model.SeverityWarn, + Then: []model.RuleAction{ + { + Field: "500", + Function: "defined", + }, + { + Field: "500", + Function: "schema", + FunctionOptions: opts, + }, + }, + HowToFix: owaspDefineErrorResponses500Fix, + } +} + +func GetOWASPRateLimitRule() *model.Rule { + var ( + xRatelimitLimit = "X-RateLimit-Limit" + xRateLimitLimit = "X-Rate-Limit-Limit" + ratelimitLimit = "RateLimit-Limit" + ratelimitReset = "RateLimit-Reset" + ) + + return &model.Rule{ + Name: "All 2XX and 4XX responses should define rate limiting headers", + Id: OwaspRateLimit, + Description: "Define proper rate limiting to avoid attackers overloading the API.", + Given: `$.paths..responses`, + Resolved: false, + Formats: model.OAS3AllFormat, + RuleCategory: model.RuleCategories[model.CategoryInfo], + Recommended: true, + Type: Validation, + Severity: model.SeverityError, + Then: model.RuleAction{ + Function: "owaspHeaderDefinition", + FunctionOptions: map[string]interface{}{ + "headers": [][]string{ + {xRatelimitLimit}, + {xRateLimitLimit}, + {ratelimitLimit, ratelimitReset}, + }, + }, + }, + HowToFix: owaspRateLimitFix, + } +} + +func GetOWASPRateLimitRetryAfterRule() *model.Rule { + + return &model.Rule{ + Name: "A 429 response should define a Retry-After header", + Id: OwaspRateLimitRetryAfter, + Description: "Define proper rate limiting to avoid attackers overloading the API. Part of that involves setting a Retry-After header so well meaning consumers are not polling and potentially exacerbating problems", + Given: `$..responses.429.headers`, + Resolved: false, + Formats: model.OAS3AllFormat, + RuleCategory: model.RuleCategories[model.CategoryInfo], + Recommended: true, + Type: Validation, + Severity: model.SeverityError, + Then: model.RuleAction{ + Field: "Retry-After", + Function: "defined", + }, + HowToFix: owaspRateLimitRetryAfterFix, + } +} + +func GetOWASPDefineErrorResponses429Rule() *model.Rule { + + opts := make(map[string]interface{}) + yml := `type: object +required: + - content` + + jsonSchema, _ := parser.ConvertYAMLIntoJSONSchema(yml, nil) + opts["schema"] = jsonSchema + opts["forceValidation"] = true // this will be picked up by the schema function to force validation. + + return &model.Rule{ + Name: "Operation is missing rate limiting response in {{property}}", + Id: OwaspDefineErrorResponses429, + Description: "OWASP API Security recommends defining schemas for all responses, even errors: 429 response error code.", + Given: `$.paths..responses`, + Resolved: false, + RuleCategory: model.RuleCategories[model.CategoryInfo], + Recommended: true, + Type: Validation, + Severity: model.SeverityWarn, + Then: []model.RuleAction{ + { + Field: "429", + Function: "defined", + }, + { + Field: "429", + Function: "schema", + FunctionOptions: opts, + }, + }, + HowToFix: owaspDefineErrorResponses429Fix, + } +} + +// It will return duplicate errors for each branch of any if/else/then logic +func GetOWASPArrayLimitRule() *model.Rule { + + // create a schema to match against. + opts := make(map[string]interface{}) + yml := `type: object +if: + properties: + type: + enum: + - array +then: + oneOf: + - required: + - maxItems +` + + jsonSchema, _ := parser.ConvertYAMLIntoJSONSchema(yml, nil) + opts["schema"] = jsonSchema + opts["forceValidationOnCurrentNode"] = true // use the current node to validate (field not needed) + + return &model.Rule{ + Name: "Schema of type array must specify maxItems", + Id: OwaspArrayLimit, + Description: "Array size should be limited to mitigate resource exhaustion attacks.", + Given: []string{ + `$..[?(@.type)]`, + }, + Resolved: false, + Formats: model.AllFormats, + RuleCategory: model.RuleCategories[model.CategoryInfo], + Recommended: true, + Type: Validation, + Severity: model.SeverityError, + Then: model.RuleAction{ + Function: "schema", + FunctionOptions: opts, + }, + HowToFix: owaspArrayLimitFix, + } +} + +// It will return duplicate errors for each branch of any if/else/then logic +func GetOWASPStringLimitRule() *model.Rule { + + // create a schema to match against. + opts := make(map[string]interface{}) + yml := `type: object +if: + properties: + type: + enum: + - string +then: + oneOf: + - required: + - maxLength + - required: + - enum + - required: + - const +else: + if: + properties: + type: + type: array + then: + if: + properties: + type: + contains: + enum: + - string + then: + oneOf: + - required: + - maxLength + - required: + - enum + - required: + - const +` + + jsonSchema, _ := parser.ConvertYAMLIntoJSONSchema(yml, nil) + opts["schema"] = jsonSchema + opts["forceValidationOnCurrentNode"] = true // use the current node to validate (field not needed) + + return &model.Rule{ + Name: "Schema of type string must specify maxLength, enum, or const", + Id: OwaspStringLimit, + Description: "String size should be limited to mitigate resource exhaustion attacks. This can be done using `maxLength`, `enum` or `const`", + Given: []string{ + `$..[?(@.type)]`, + }, + Resolved: false, + Formats: model.AllFormats, + RuleCategory: model.RuleCategories[model.CategoryInfo], + Recommended: true, + Type: Validation, + Severity: model.SeverityError, + Then: model.RuleAction{ + Function: "schema", + FunctionOptions: opts, + }, + HowToFix: owaspStringLimitFix, + } +} + +// It will return duplicate errors for each branch of any if/else/then logic +func GetOWASPStringRestrictedRule() *model.Rule { + + // create a schema to match against. + opts := make(map[string]interface{}) + yml := `type: object +if: + properties: + type: + enum: + - string +then: + anyOf: + - required: + - format + - required: + - pattern + - required: + - enum + - required: + - const +else: + if: + properties: + type: + type: array + then: + if: + properties: + type: + contains: + enum: + - string + then: + anyOf: + - required: + - format + - required: + - pattern + - required: + - enum + - required: + - const +` + + jsonSchema, _ := parser.ConvertYAMLIntoJSONSchema(yml, nil) + opts["schema"] = jsonSchema + opts["forceValidationOnCurrentNode"] = true // use the current node to validate (field not needed) + + return &model.Rule{ + Name: "Schema of type string must specify a format, pattern, enum, or const", + Id: OwaspStringRestricted, + Description: "To avoid unexpected values being sent or leaked, ensure that strings have either a `format`, RegEx `pattern`, `enum`, or `const`", + Given: []string{ + `$..[?(@.type)]`, + }, + Resolved: false, + Formats: model.AllFormats, + RuleCategory: model.RuleCategories[model.CategoryInfo], + Recommended: true, + Type: Validation, + Severity: model.SeverityError, + Then: model.RuleAction{ + Function: "schema", + FunctionOptions: opts, + }, + HowToFix: owaspStringRestrictedFix, + } +} + +// It will return duplicate errors for each branch of any if/else/then logic +func GetOWASPIntegerLimitRule() *model.Rule { + + // create a schema to match against. + opts := make(map[string]interface{}) + yml := `type: object +if: + properties: + type: + enum: + - integer +then: + not: + oneOf: + - required: + - exclusiveMinimum + - minimum + - required: + - exclusiveMaximum + - maximum + oneOf: + - required: + - minimum + - maximum + - required: + - minimum + - exclusiveMaximum + - required: + - exclusiveMinimum + - maximum + - required: + - exclusiveMinimum + - exclusiveMaximum +else: + if: + properties: + type: + type: array + then: + if: + properties: + type: + contains: + enum: + - integer + then: + not: + oneOf: + - required: + - exclusiveMinimum + - minimum + - required: + - exclusiveMaximum + - maximum + oneOf: + - required: + - minimum + - maximum + - required: + - minimum + - exclusiveMaximum + - required: + - exclusiveMinimum + - maximum + - required: + - exclusiveMinimum + - exclusiveMaximum +` + + jsonSchema, _ := parser.ConvertYAMLIntoJSONSchema(yml, nil) + opts["schema"] = jsonSchema + opts["forceValidationOnCurrentNode"] = true // use the current node to validate (field not needed) + + return &model.Rule{ + Name: "Schema of type integer must specify minimum and maximum", + Id: OwaspIntegerLimit, + Description: "Integers should be limited to mitigate resource exhaustion attacks.", + Given: []string{ + `$..[?(@.type)]`, + }, + Resolved: false, + Formats: model.AllFormats, + RuleCategory: model.RuleCategories[model.CategoryInfo], + Recommended: true, + Type: Validation, + Severity: model.SeverityError, + Then: model.RuleAction{ + Function: "schema", + FunctionOptions: opts, + }, + HowToFix: owaspIntegerLimitFix, + } +} + +// It will return duplicate errors for each branch of any if/else/then logic +func GetOWASPIntegerLimitLegacyRule() *model.Rule { + + // create a schema to match against. + opts := make(map[string]interface{}) + yml := `type: object +if: + properties: + type: + enum: + - integer +then: + allOf: + - required: + - minimum + - required: + - maximum +else: + if: + properties: + type: + type: array + then: + if: + properties: + type: + contains: + enum: + - integer + then: + allOf: + - required: + - minimum + - required: + - maximum +` + + jsonSchema, _ := parser.ConvertYAMLIntoJSONSchema(yml, nil) + opts["schema"] = jsonSchema + opts["forceValidationOnCurrentNode"] = true // use the current node to validate (field not needed) + + return &model.Rule{ + Name: "Schema of type integer must specify minimum and maximum", + Id: OwaspIntegerLimitLegacy, + Description: "Integers should be limited to mitigate resource exhaustion attacks.", + Given: []string{ + `$..[?(@.type)]`, + }, + Resolved: false, + Formats: model.AllFormats, + RuleCategory: model.RuleCategories[model.CategoryInfo], + Recommended: true, + Type: Validation, + Severity: model.SeverityError, + Then: model.RuleAction{ + Function: "schema", + FunctionOptions: opts, + }, + HowToFix: owaspIntegerLimitFix, + } +} + +// It will return duplicate errors for each branch of any if/else/then logic +func GetOWASPIntegerFormatRule() *model.Rule { + + // create a schema to match against. + opts := make(map[string]interface{}) + yml := `type: object +if: + properties: + type: + enum: + - integer +then: + allOf: + - required: + - format +else: + if: + properties: + type: + type: array + then: + if: + properties: + type: + contains: + enum: + - integer + then: + allOf: + - required: + - format +` + + jsonSchema, _ := parser.ConvertYAMLIntoJSONSchema(yml, nil) + opts["schema"] = jsonSchema + opts["forceValidationOnCurrentNode"] = true // use the current node to validate (field not needed) + + return &model.Rule{ + Name: "Schema of type integer must specify format (int32 or int64)", + Id: OwaspIntegerFormat, + Description: "Integers should be limited to mitigate resource exhaustion attacks.", + Given: []string{ + `$..[?(@.type)]`, + }, + Resolved: false, + Formats: model.AllFormats, + RuleCategory: model.RuleCategories[model.CategoryInfo], + Recommended: true, + Type: Validation, + Severity: model.SeverityError, + Then: model.RuleAction{ + Function: "schema", + FunctionOptions: opts, + }, + HowToFix: owaspIntegerFormatFix, + } +} + +func GetOWASPNoAdditionalPropertiesRule() *model.Rule { + + return &model.Rule{ + Name: "If the additionalProperties keyword is used it must be set to false", + Id: OwaspNoAdditionalProperties, + Description: "By default JSON Schema allows additional properties, which can potentially lead to mass assignment issues.", + Given: `$..[?(@.type=="object" && @.additionalProperties)]`, + Resolved: false, + Formats: append(model.OAS2Format, model.OAS3Format...), + RuleCategory: model.RuleCategories[model.CategoryInfo], + Recommended: true, + Type: Validation, + Severity: model.SeverityWarn, + Then: []model.RuleAction{ + { + Field: "additionalProperties", + Function: "falsy", + }, + }, + HowToFix: owaspNoAdditionalPropertiesFix, + } +} + +func GetOWASPConstrainedAdditionalPropertiesRule() *model.Rule { + + return &model.Rule{ + Name: "Objects should not allow unconstrained additionalProperties", + Id: OwaspConstrainedAdditionalProperties, + Description: "By default JSON Schema allows additional properties, which can potentially lead to mass assignment issues.", + Given: `$..[?(@.type=="object" && @.additionalProperties!=true && @.additionalProperties!=false )]`, + Resolved: false, + Formats: model.OAS3AllFormat, + RuleCategory: model.RuleCategories[model.CategoryInfo], + Recommended: true, + Type: Validation, + Severity: model.SeverityWarn, + Then: model.RuleAction{ + Field: "maxProperties", + Function: "defined", + }, + HowToFix: owaspNoAdditionalPropertiesFix, + } +} + +func GetOWASPSecurityHostsHttpsOAS2Rule() *model.Rule { + + opts := make(map[string]interface{}) + yml := `type: array +items: + type: "string" + enum: [https]` + + jsonSchema, _ := parser.ConvertYAMLIntoJSONSchema(yml, nil) + opts["schema"] = jsonSchema + opts["forceValidationOnCurrentNode"] = true // use the current node to validate (field not needed) + + return &model.Rule{ + Name: "All servers defined MUST use https, and no other protocol is permitted", + Id: OwaspSecurityHostsHttpsOAS2, + Description: "All server interactions MUST use the https protocol, so the only OpenAPI scheme being used should be `https`.", + Given: []string{ + `$.schemes`, + }, + Resolved: false, + Formats: model.OAS2Format, + RuleCategory: model.RuleCategories[model.CategoryInfo], + Recommended: true, + Type: Validation, + Severity: model.SeverityError, + Then: model.RuleAction{ + Function: "schema", + FunctionOptions: opts, + }, + HowToFix: owaspSecurityHostsHttpsOAS2Fix, + } +} + +func GetOWASPSecurityHostsHttpsOAS3Rule() *model.Rule { + + // create a schema to match against. + opts := make(map[string]interface{}) + opts["match"] = "^https:" + comp, _ := regexp.Compile(opts["match"].(string)) + + return &model.Rule{ + Name: "Server URLs MUST begin https://, and no other protocol is permitted", + Id: OwaspSecurityHostsHttpsOAS3, + Description: "All server interactions MUST use the https protocol, meaning server URLs should begin `https://`.", + Given: []string{ + `$.servers..url`, + }, + Resolved: false, + Formats: model.OAS3Format, + RuleCategory: model.RuleCategories[model.CategoryInfo], + Recommended: true, + Type: Validation, + Severity: model.SeverityError, + Then: model.RuleAction{ + Function: "pattern", + FunctionOptions: opts, + }, + PrecompiledPattern: comp, + HowToFix: owaspSecurityHostsHttpsOAS3Fix, + } +} diff --git a/rulesets/rule_fixes.go b/rulesets/rule_fixes.go index 22f17908..5953d788 100644 --- a/rulesets/rule_fixes.go +++ b/rulesets/rule_fixes.go @@ -154,3 +154,27 @@ const ( "used to inform clients they are using the API incorrectly, with bad input, or malformed requests. An API with no errors" + "defined is really hard to navigate." ) + +const ( + owaspNoNumericIDsFix = "For any parameter which ends in id, use type string with uuid format instead of type integer." + owaspNoHttpBasicFix = "Do not use basic authentication method, use a more secure authentication method (e.g., bearer)." + owaspNoAPIKeysInURLFix = "Make sure that the apiKey is not part of the URL (path or query): https://blog.stoplight.io/api-keys-best-practices-to-authenticate-apis" + owaspNoCredentialsInURLFix = "Remove credentials from the URL." + owaspAuthInsecureSchemesFix = "Use a different authorization scheme. Refer to https://www.iana.org/assignments/http-authschemes/ to know more about HTTP Authentication Schemes." + owaspJWTBestPracticesFix = "Explicitly state, in the description of the security schemes, that it allows for support of the RFC8725: https://datatracker.ietf.org/doc/html/rfc8725." + owaspRateLimitFix = "Implement rate-limiting using HTTP headers: https://datatracker.ietf.org/doc/draft-ietf-httpapi-ratelimit-headers/ Customer headers like X-Rate-Limit-Limit (Twitter: https://developer.twitter.com/en/docs/twitter-api/rate-limits) or X-RateLimit-Limit (GitHub: https://docs.github.com/en/rest/overview/resources-in-the-rest-api)" + owaspRateLimitRetryAfterFix = "Set the Retry-After header in the 429 response." + owaspProtectionFix = "Make sure that all operations should be protected especially when they are not safe (methods that do not alter the state of the server) HTTP methods like `POST`, `PUT`, `PATCH`, and `DELETE`. This is done with one or more non-empty `security` rules. Security rules are defined in the `securityScheme` section." + owaspDefineErrorValidationFix = "Extend the responses of all endpoints to support either 400, 422, or 4XX error codes." + owaspDefineErrorResponses401Fix = "For all endpoints, make sure that the 401 response code is defined as well as its contents." + owaspDefineErrorResponses500Fix = "For all endpoints, make sure that the 500 response code is defined as well as its contents." + owaspDefineErrorResponses429Fix = "For all endpoints, make sure that the 429 response code is defined as well as its contents." + owaspArrayLimitFix = "Add `maxItems` for Schema of type 'array'. You should ensure that the subschema in `items` is constrained too." + owaspStringLimitFix = "Use `maxLength`, `enum`, or `const`." + owaspStringRestrictedFix = "Ensure that strings have either a `format`, RegEx `pattern`, `enum`, or `const`." + owaspIntegerLimitFix = "Use `minimum` and `maximum` properties for integer types: avoiding negative numbers when positive are expected, or reducing unreasonable iterations like doing something 1000 times when 10 is expected." + owaspIntegerFormatFix = "Specify whether int32 or int64 is expected via `format`." + owaspNoAdditionalPropertiesFix = "Disable additional properties by setting `additionalProperties` to `false` or add `maxProperties`." + owaspSecurityHostsHttpsOAS2Fix = "Ensure that you are using the HTTPS protocol. Learn more about the importance of TLS (over SSL) here: https://cheatsheetseries.owasp.org/cheatsheets/Transport_Layer_Protection_Cheat_Sheet.html." + owaspSecurityHostsHttpsOAS3Fix = "Prefix server URLs with the HTTPS protocol: `https://`. Learn more about the importance of TLS (over SSL) here: https://cheatsheetseries.owasp.org/cheatsheets/Transport_Layer_Protection_Cheat_Sheet.html." +) diff --git a/rulesets/rulesets.go b/rulesets/rulesets.go index 7f68090d..5e33c5dc 100644 --- a/rulesets/rulesets.go +++ b/rulesets/rulesets.go @@ -8,77 +8,105 @@ import ( "encoding/json" "errors" "fmt" + "strings" + "github.com/daveshanley/vacuum/model" "github.com/mitchellh/mapstructure" "github.com/pb33f/libopenapi/utils" "github.com/santhosh-tekuri/jsonschema/v5" "go.uber.org/zap" - "strings" ) //go:embed schemas/ruleset.schema.json var rulesetSchema string const ( - Style = "style" - Validation = "validation" - NoVerbsInPath = "no-http-verbs-in-path" - PathsKebabCase = "paths-kebab-case" - NoAmbiguousPathsRule = "no-ambiguous-paths" - OperationErrorResponse = "operation-4xx-response" - OperationSuccessResponse = "operation-success-response" - OperationOperationIdUnique = "operation-operationId-unique" - OperationOperationId = "operation-operationId" - OperationParameters = "operation-parameters" - OperationSingularTag = "operation-singular-tag" - OperationTagDefined = "operation-tag-defined" - PathParamsRule = "path-params" - ContactProperties = "contact-properties" - InfoContact = "info-contact" - InfoDescription = "info-description" - InfoLicense = "info-license" - LicenseUrl = "license-url" - OpenAPITagsAlphabetical = "openapi-tags-alphabetical" - OpenAPITags = "openapi-tags" - OperationTags = "operation-tags" - OperationDescription = "operation-description" - ComponentDescription = "component-description" - OperationOperationIdValidInUrl = "operation-operationId-valid-in-url" - PathDeclarationsMustExist = "path-declarations-must-exist" - PathKeysNoTrailingSlash = "path-keys-no-trailing-slash" - PathNotIncludeQuery = "path-not-include-query" - TagDescription = "tag-description" - NoRefSiblings = "no-$ref-siblings" - Oas3UnusedComponent = "oas3-unused-component" - Oas2UnusedDefinition = "oas2-unused-definition" - Oas2APIHost = "oas2-api-host" - Oas2APISchemes = "oas2-api-schemes" - Oas2Discriminator = "oas2-discriminator" - Oas2HostNotExample = "oas2-host-not-example" - Oas3HostNotExample = "oas3-host-not-example.com" - Oas2HostTrailingSlash = "oas2-host-trailing-slash" - Oas3HostTrailingSlash = "oas3-host-trailing-slash" - Oas2ParameterDescription = "oas2-parameter-description" - Oas3ParameterDescription = "oas3-parameter-description" - Oas3OperationSecurityDefined = "oas3-operation-security-defined" - Oas2OperationSecurityDefined = "oas2-operation-security-defined" - Oas3ValidSchemaExample = "oas3-valid-schema-example" - Oas2ValidSchemaExample = "oas2-valid-schema-example" - TypedEnum = "typed-enum" - DuplicatedEntryInEnum = "duplicated-entry-in-enum" - NoEvalInMarkdown = "no-eval-in-markdown" - NoScriptTagsInMarkdown = "no-script-tags-in-markdown" - DescriptionDuplication = "description-duplication" - Oas3APIServers = "oas3-api-servers" - Oas2OperationFormDataConsumeCheck = "oas2-operation-formData-consume-check" - Oas2AnyOf = "oas2-anyOf" - Oas2OneOf = "oas2-oneOf" - Oas2Schema = "oas2-schema" - Oas3Schema = "oas3-schema" - SpectralOpenAPI = "spectral:oas" - SpectralRecommended = "recommended" - SpectralAll = "all" - SpectralOff = "off" + Style = "style" + Validation = "validation" + NoVerbsInPath = "no-http-verbs-in-path" + PathsKebabCase = "paths-kebab-case" + NoAmbiguousPathsRule = "no-ambiguous-paths" + OperationErrorResponse = "operation-4xx-response" + OperationSuccessResponse = "operation-success-response" + OperationOperationIdUnique = "operation-operationId-unique" + OperationOperationId = "operation-operationId" + OperationParameters = "operation-parameters" + OperationSingularTag = "operation-singular-tag" + OperationTagDefined = "operation-tag-defined" + PathParamsRule = "path-params" + ContactProperties = "contact-properties" + InfoContact = "info-contact" + InfoDescription = "info-description" + InfoLicense = "info-license" + LicenseUrl = "license-url" + OpenAPITagsAlphabetical = "openapi-tags-alphabetical" + OpenAPITags = "openapi-tags" + OperationTags = "operation-tags" + OperationDescription = "operation-description" + ComponentDescription = "component-description" + OperationOperationIdValidInUrl = "operation-operationId-valid-in-url" + PathDeclarationsMustExist = "path-declarations-must-exist" + PathKeysNoTrailingSlash = "path-keys-no-trailing-slash" + PathNotIncludeQuery = "path-not-include-query" + TagDescription = "tag-description" + NoRefSiblings = "no-$ref-siblings" + Oas3UnusedComponent = "oas3-unused-component" + Oas2UnusedDefinition = "oas2-unused-definition" + Oas2APIHost = "oas2-api-host" + Oas2APISchemes = "oas2-api-schemes" + Oas2Discriminator = "oas2-discriminator" + Oas2HostNotExample = "oas2-host-not-example" + Oas3HostNotExample = "oas3-host-not-example.com" + Oas2HostTrailingSlash = "oas2-host-trailing-slash" + Oas3HostTrailingSlash = "oas3-host-trailing-slash" + Oas2ParameterDescription = "oas2-parameter-description" + Oas3ParameterDescription = "oas3-parameter-description" + Oas3OperationSecurityDefined = "oas3-operation-security-defined" + Oas2OperationSecurityDefined = "oas2-operation-security-defined" + Oas3ValidSchemaExample = "oas3-valid-schema-example" + Oas2ValidSchemaExample = "oas2-valid-schema-example" + TypedEnum = "typed-enum" + DuplicatedEntryInEnum = "duplicated-entry-in-enum" + NoEvalInMarkdown = "no-eval-in-markdown" + NoScriptTagsInMarkdown = "no-script-tags-in-markdown" + DescriptionDuplication = "description-duplication" + Oas3APIServers = "oas3-api-servers" + Oas2OperationFormDataConsumeCheck = "oas2-operation-formData-consume-check" + Oas2AnyOf = "oas2-anyOf" + Oas2OneOf = "oas2-oneOf" + Oas2Schema = "oas2-schema" + Oas3Schema = "oas3-schema" + OwaspNoNumericIDs = "owasp-no-numeric-ids" + OwaspNoHttpBasic = "owasp-no-http-basic" + OwaspNoAPIKeysInURL = "owasp-no-api-keys-in-url" + OwaspNoCredentialsInURL = "owasp-no-credentials-in-url" + OwaspAuthInsecureSchemes = "owasp-auth-insecure-schemes" + OwaspJWTBestPractices = "owasp-jwt-best-practices" + OwaspProtectionGlobalUnsafe = "owasp-protection-global-unsafe" + OwaspProtectionGlobalUnsafeStrict = "owasp-protection-global-unsafe-strict" + OwaspProtectionGlobalSafe = "owasp-protection-global-safe" + OwaspDefineErrorValidation = "owasp-define-error-validation" + OwaspDefineErrorResponses401 = "owasp-define-error-responses-401" + OwaspDefineErrorResponses500 = "owasp-define-error-responses-500" + OwaspRateLimit = "owasp-rate-limit" + OwaspRateLimitRetryAfter = "owasp-rate-limit-retry-after" + OwaspDefineErrorResponses429 = "owasp-define-error-responses-429" + OwaspArrayLimit = "owasp-array-limit" + OwaspStringLimit = "owasp-string-limit" + OwaspStringRestricted = "owasp-string-restricted" + OwaspIntegerLimit = "owasp-integer-limit" + OwaspIntegerLimitLegacy = "owasp-integer-limit-legacy" + OwaspIntegerFormat = "owasp-integer-format" + OwaspNoAdditionalProperties = "owasp-no-additionalProperties" + OwaspConstrainedAdditionalProperties = "owasp-constrained-additionalProperties" + OwaspSecurityHostsHttpsOAS2 = "owasp-security-hosts-https-oas2" + OwaspSecurityHostsHttpsOAS3 = "owasp-security-hosts-https-oas3" + SpectralOpenAPI = "spectral:oas" + SpectralOwasp = "spectral:owasp" + VacuumOwasp = "vacuum:owasp" + SpectralRecommended = "recommended" + SpectralAll = "all" + SpectralOff = "off" ) var log *zap.Logger @@ -178,6 +206,13 @@ func (rsm ruleSetsModel) GenerateRuleSetFromSuppliedRuleSet(ruleset *RuleSet) *R rs.Rules = make(map[string]*model.Rule) } + // owasp rules with spectral and vacuum namespace + if extends[SpectralOwasp] == SpectralAll || extends[VacuumOwasp] == SpectralAll { + for ruleName, rule := range GetAllOWASPRules() { + rs.Rules[ruleName] = rule + } + } + // add definitions. rs.RuleDefinitions = ruleset.RuleDefinitions @@ -330,6 +365,38 @@ func GetAllBuiltInRules() map[string]*model.Rule { return rules } +// GetAllOWASPRules returns a map of all the OWASP rules available, ready to be used in a RuleSet. +func GetAllOWASPRules() map[string]*model.Rule { + rules := make(map[string]*model.Rule) + + rules[OwaspNoNumericIDs] = GetOWASPNoNumericIDsRule() + rules[OwaspNoHttpBasic] = GetOWASPNoHttpBasicRule() + rules[OwaspNoAPIKeysInURL] = GetOWASPNoAPIKeysInURLRule() + rules[OwaspNoCredentialsInURL] = GetOWASPNoCredentialsInURLRule() + rules[OwaspAuthInsecureSchemes] = GetOWASPAuthInsecureSchemesRule() + rules[OwaspJWTBestPractices] = GetOWASPJWTBestPracticesRule() + rules[OwaspProtectionGlobalUnsafe] = GetOWASPProtectionGlobalUnsafeRule() + rules[OwaspProtectionGlobalUnsafeStrict] = GetOWASPProtectionGlobalUnsafeStrictRule() + rules[OwaspProtectionGlobalSafe] = GetOWASPProtectionGlobalSafeRule() + rules[OwaspDefineErrorValidation] = GetOWASPDefineErrorValidationRule() + rules[OwaspDefineErrorResponses401] = GetOWASPDefineErrorResponses401Rule() + rules[OwaspDefineErrorResponses500] = GetOWASPDefineErrorResponses500Rule() + rules[OwaspRateLimit] = GetOWASPRateLimitRule() + rules[OwaspRateLimitRetryAfter] = GetOWASPRateLimitRetryAfterRule() + rules[OwaspDefineErrorResponses429] = GetOWASPDefineErrorResponses429Rule() + rules[OwaspArrayLimit] = GetOWASPArrayLimitRule() + rules[OwaspStringLimit] = GetOWASPStringLimitRule() + rules[OwaspStringRestricted] = GetOWASPStringRestrictedRule() + rules[OwaspIntegerLimit] = GetOWASPIntegerLimitRule() + rules[OwaspIntegerLimitLegacy] = GetOWASPIntegerLimitLegacyRule() + rules[OwaspIntegerFormat] = GetOWASPIntegerFormatRule() + rules[OwaspNoAdditionalProperties] = GetOWASPNoAdditionalPropertiesRule() + rules[OwaspConstrainedAdditionalProperties] = GetOWASPConstrainedAdditionalPropertiesRule() + rules[OwaspSecurityHostsHttpsOAS2] = GetOWASPSecurityHostsHttpsOAS2Rule() + rules[OwaspSecurityHostsHttpsOAS3] = GetOWASPSecurityHostsHttpsOAS3Rule() + return rules +} + // GenerateDefaultOpenAPIRuleSet generates a default ruleset for OpenAPI. All the built in rules, ready to go. func GenerateDefaultOpenAPIRuleSet() *RuleSet { set := &RuleSet{ diff --git a/rulesets/rulesets_test.go b/rulesets/rulesets_test.go index 42d163da..8315d436 100644 --- a/rulesets/rulesets_test.go +++ b/rulesets/rulesets_test.go @@ -2,12 +2,14 @@ package rulesets import ( "fmt" + "testing" + "github.com/daveshanley/vacuum/model" "github.com/stretchr/testify/assert" - "testing" ) var totalRules = 53 +var totalOwaspRules = 25 var totalRecommendedRules = 42 func TestBuildDefaultRuleSets(t *testing.T) { @@ -353,6 +355,28 @@ rules: } +func TestRuleSetsModel_GenerateRuleSetFromConfig_Oas_SpectralOwasp(t *testing.T) { + + yaml := `extends: [[spectral:oas, all], [spectral:owasp, all]]` + + def := BuildDefaultRuleSets() + rs, _ := CreateRuleSetFromData([]byte(yaml)) + repl := def.GenerateRuleSetFromSuppliedRuleSet(rs) + assert.Len(t, repl.Rules, totalOwaspRules+totalRules) + +} + +func TestRuleSetsModel_GenerateRuleSetFromConfig_Oas_VacuumOwasp(t *testing.T) { + + yaml := `extends: [[spectral:oas, all], [vacuum:owasp, all]]` + + def := BuildDefaultRuleSets() + rs, _ := CreateRuleSetFromData([]byte(yaml)) + repl := def.GenerateRuleSetFromSuppliedRuleSet(rs) + assert.Len(t, repl.Rules, totalOwaspRules+totalRules) + +} + func TestGetAllBuiltInRules(t *testing.T) { assert.Len(t, GetAllBuiltInRules(), totalRules) }