From 7cbdbca1c24b2c88ad75642d100632d359939ae9 Mon Sep 17 00:00:00 2001 From: Emmanuel Gautier Date: Wed, 6 Mar 2024 18:52:58 +0100 Subject: [PATCH] feat: scan for discoverable openapi files --- README.md | 3 + cmd/scan/curl.go | 2 +- cmd/scan/openapi.go | 2 +- internal/request/operation.go | 16 ++-- internal/request/operation_test.go | 27 +++++- scan/best_practices.go | 12 +-- scan/discover.go | 11 +++ scan/discover/discoverable_openapi.go | 83 +++++++++++++++++++ scan/discover/discoverable_openapi_test.go | 60 ++++++++++++++ .../server_signature.go | 16 ++-- .../server_signature_test.go | 14 ++-- scan/scan.go | 58 ++++++++----- scan/vulns.go | 8 +- 13 files changed, 254 insertions(+), 58 deletions(-) create mode 100644 scan/discover.go create mode 100644 scan/discover/discoverable_openapi.go create mode 100644 scan/discover/discoverable_openapi_test.go rename scan/{best_practices => discover}/server_signature.go (67%) rename scan/{best_practices => discover}/server_signature_test.go (77%) diff --git a/README.md b/README.md index 8530ac2..911f025 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,10 @@ The scanner also detects the following security best practices: * X-Frame-Options Header is not set * HTTP Trace Method enabled * HTTP Cookies not marked as secure, httpOnly, or SameSite + +The scanner perform some discoverability scans: * Server Signature exposed +* Discovery of API endpoints using OpenAPI contracts > More vulnerabilities and best practices will be added in future releases. If you have any suggestions or requests for additional vulnerabilities or best practices to be included, please feel free to open an issue or submit a pull request. diff --git a/cmd/scan/curl.go b/cmd/scan/curl.go index 697156e..94bd610 100644 --- a/cmd/scan/curl.go +++ b/cmd/scan/curl.go @@ -47,7 +47,7 @@ func NewCURLScanCmd() (scanCmd *cobra.Command) { log.Fatal(err) } - if reporter, _, err = scan.WithAllVulnsScans().WithAllBestPracticesScans().Execute(); err != nil { + if reporter, _, err = scan.WithAllScans().Execute(); err != nil { log.Fatal(err) } }, diff --git a/cmd/scan/openapi.go b/cmd/scan/openapi.go index cb22636..45284ae 100644 --- a/cmd/scan/openapi.go +++ b/cmd/scan/openapi.go @@ -43,7 +43,7 @@ func NewOpenAPIScanCmd() (scanCmd *cobra.Command) { log.Fatal(err) } - if reporter, _, err = scan.WithAllVulnsScans().WithAllBestPracticesScans().Execute(); err != nil { + if reporter, _, err = scan.WithAllScans().Execute(); err != nil { log.Fatal(err) } }, diff --git a/internal/request/operation.go b/internal/request/operation.go index b5ead8c..60d1850 100644 --- a/internal/request/operation.go +++ b/internal/request/operation.go @@ -44,16 +44,22 @@ func NewOperation(url, method string, headers *http.Header, cookies []http.Cooki } func (o *Operation) Clone() *Operation { - clonedHeaders := make(http.Header) + var clonedHeaders http.Header if o.Headers != nil { clonedHeaders = o.Headers.Clone() } - clonedCookies := make([]http.Cookie, len(o.Cookies)) - copy(clonedCookies, o.Cookies) + var clonedCookies []http.Cookie + if o.Cookies != nil { + clonedCookies = make([]http.Cookie, len(o.Cookies)) + copy(clonedCookies, o.Cookies) + } - clonedSecuritySchemes := make([]auth.SecurityScheme, len(o.SecuritySchemes)) - copy(clonedSecuritySchemes, o.SecuritySchemes) + var clonedSecuritySchemes []auth.SecurityScheme + if o.SecuritySchemes != nil { + clonedSecuritySchemes = make([]auth.SecurityScheme, len(o.SecuritySchemes)) + copy(clonedSecuritySchemes, o.SecuritySchemes) + } return NewOperation(o.Url, o.Method, &clonedHeaders, clonedCookies, clonedSecuritySchemes) } diff --git a/internal/request/operation_test.go b/internal/request/operation_test.go index 82da5f1..b6be095 100644 --- a/internal/request/operation_test.go +++ b/internal/request/operation_test.go @@ -58,10 +58,20 @@ func TestNewOperationWithNoSecuritySchemes(t *testing.T) { assert.Len(t, operation.SecuritySchemes, 1) } -func TestOperation_Clone(t *testing.T) { +func TestOperationCloneWithHeader(t *testing.T) { headers := http.Header{} headers.Add("Content-Type", "application/json") + operation := request.NewOperation("http://example.com", "GET", &headers, nil, nil) + + clonedOperation := operation.Clone() + + assert.Equal(t, operation.Url, clonedOperation.Url) + assert.Equal(t, operation.Method, clonedOperation.Method) + assert.Equal(t, operation.Headers, clonedOperation.Headers) +} + +func TestOperationCloneWithCookies(t *testing.T) { cookies := []http.Cookie{ { Name: "cookie1", @@ -73,12 +83,23 @@ func TestOperation_Clone(t *testing.T) { }, } - operation := request.NewOperation("http://example.com", "GET", &headers, cookies, nil) + operation := request.NewOperation("http://example.com", "GET", nil, cookies, nil) clonedOperation := operation.Clone() assert.Equal(t, operation.Url, clonedOperation.Url) assert.Equal(t, operation.Method, clonedOperation.Method) - assert.Equal(t, operation.Headers, clonedOperation.Headers) assert.Equal(t, operation.Cookies, clonedOperation.Cookies) } + +func TestOperationCloneWithSecuritySchemes(t *testing.T) { + securitySchemes := []auth.SecurityScheme{auth.NewNoAuthSecurityScheme()} + + operation := request.NewOperation("http://example.com", "GET", nil, nil, securitySchemes) + + clonedOperation := operation.Clone() + + assert.Equal(t, operation.Url, clonedOperation.Url) + assert.Equal(t, operation.Method, clonedOperation.Method) + assert.Equal(t, operation.SecuritySchemes, clonedOperation.SecuritySchemes) +} diff --git a/scan/best_practices.go b/scan/best_practices.go index 54d69d3..0ed295e 100644 --- a/scan/best_practices.go +++ b/scan/best_practices.go @@ -5,21 +5,17 @@ import ( ) func (s *Scan) WithHTTPHeadersBestPracticesScan() *Scan { - return s.AddScanHandler(bestpractices.HTTPHeadersBestPracticesScanHandler) + return s.AddOperationScanHandler(bestpractices.HTTPHeadersBestPracticesScanHandler) } func (s *Scan) WithHTTPTraceMethodBestPracticesScan() *Scan { - return s.AddScanHandler(bestpractices.HTTPTraceMethodScanHandler) -} - -func (s *Scan) WithServerSignatureScan() *Scan { - return s.AddScanHandler(bestpractices.ServerSignatureScanHandler) + return s.AddOperationScanHandler(bestpractices.HTTPTraceMethodScanHandler) } func (s *Scan) WithHTTPCookiesBestPracticesScan() *Scan { - return s.AddScanHandler(bestpractices.HTTPCookiesScanHandler) + return s.AddOperationScanHandler(bestpractices.HTTPCookiesScanHandler) } func (s *Scan) WithAllBestPracticesScans() *Scan { - return s.WithHTTPHeadersBestPracticesScan().WithHTTPTraceMethodBestPracticesScan().WithServerSignatureScan().WithHTTPCookiesBestPracticesScan() + return s.WithHTTPHeadersBestPracticesScan().WithHTTPTraceMethodBestPracticesScan().WithHTTPCookiesBestPracticesScan() } diff --git a/scan/discover.go b/scan/discover.go new file mode 100644 index 0000000..b2d3572 --- /dev/null +++ b/scan/discover.go @@ -0,0 +1,11 @@ +package scan + +import "github.com/cerberauth/vulnapi/scan/discover" + +func (s *Scan) WithServerSignatureScan() *Scan { + return s.AddScanHandler(discover.ServerSignatureScanHandler) +} + +func (s *Scan) WithAllDiscoverScans() *Scan { + return s.WithServerSignatureScan() +} diff --git a/scan/discover/discoverable_openapi.go b/scan/discover/discoverable_openapi.go new file mode 100644 index 0000000..128c670 --- /dev/null +++ b/scan/discover/discoverable_openapi.go @@ -0,0 +1,83 @@ +package discover + +import ( + "fmt" + "net/url" + + "github.com/cerberauth/vulnapi/internal/auth" + "github.com/cerberauth/vulnapi/internal/request" + "github.com/cerberauth/vulnapi/internal/scan" + "github.com/cerberauth/vulnapi/report" +) + +const ( + DiscoverableOpenAPISeverityLevel = 1 + DiscoverableOpenAPIVulnerabilityName = "Discoverable OpenAPI" + DiscoverableOpenAPIVulnerabilityDescription = "An OpenAPI file is exposed without protection. This can lead to information disclosure and security issues" +) + +var possibleOpenAPIPaths = []string{ + "/openapi", + "/swagger.json", + "/swagger.yaml", + "/openapi.json", + "/openapi.yaml", + "/api-docs", + "/api-docs.json", + "/api-docs.yaml", + "/api-docs.yml", + "/v2/api-docs", + "/v3/api-docs", + ".well-known/openapi.json", + ".well-known/openapi.yaml", +} + +func extractBase(inputURL string) (*url.URL, error) { + parsedURL, err := url.Parse(inputURL) + if err != nil { + return nil, err + } + + baseURL := fmt.Sprintf("%s://%s%s", parsedURL.Scheme, parsedURL.Host, parsedURL.Path) + base, err := url.Parse(baseURL) + if err != nil { + return nil, err + } + + return base, nil +} + +func DiscoverableOpenAPIScanHandler(operation *request.Operation, securityScheme auth.SecurityScheme) (*report.ScanReport, error) { + r := report.NewScanReport() + + securityScheme.SetAttackValue(securityScheme.GetValidValue()) + + base, err := extractBase(operation.Url) + if err != nil { + return r, err + } + + for _, path := range possibleOpenAPIPaths { + newOperation := operation.Clone() + newOperation.Url = base.ResolveReference(&url.URL{Path: path}).String() + + attempt, err := scan.ScanURL(newOperation, &securityScheme) + r.AddScanAttempt(attempt).End() + if err != nil { + return r, err + } + + if attempt.Response.StatusCode < 300 { + r.AddVulnerabilityReport(&report.VulnerabilityReport{ + SeverityLevel: DiscoverableOpenAPISeverityLevel, + Name: DiscoverableOpenAPIVulnerabilityName, + Description: DiscoverableOpenAPIVulnerabilityDescription, + Operation: newOperation, + }) + + return r, nil + } + } + + return r, nil +} diff --git a/scan/discover/discoverable_openapi_test.go b/scan/discover/discoverable_openapi_test.go new file mode 100644 index 0000000..f1ea60a --- /dev/null +++ b/scan/discover/discoverable_openapi_test.go @@ -0,0 +1,60 @@ +package discover_test + +import ( + "net/http" + "testing" + + "github.com/cerberauth/vulnapi/internal/auth" + "github.com/cerberauth/vulnapi/internal/request" + "github.com/cerberauth/vulnapi/report" + "github.com/cerberauth/vulnapi/scan/discover" + "github.com/jarcoal/httpmock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDiscoverableScannerWithNoDiscoverableOpenAPI(t *testing.T) { + httpmock.Activate() + defer httpmock.DeactivateAndReset() + + securityScheme := auth.NewNoAuthSecurityScheme() + operation := request.NewOperation("http://localhost:8080/", "GET", nil, nil, nil) + + httpmock.RegisterResponder(operation.Method, operation.Url, httpmock.NewBytesResponder(204, nil).HeaderAdd(http.Header{"Server": []string{"Apache/2.4.29 (Ubuntu)"}})) + httpmock.RegisterNoResponder(func(req *http.Request) (*http.Response, error) { + return httpmock.NewStringResponse(404, "Not Found"), nil + }) + + report, err := discover.DiscoverableOpenAPIScanHandler(operation, securityScheme) + + require.NoError(t, err) + assert.Greater(t, httpmock.GetTotalCallCount(), 10) + assert.False(t, report.HasVulnerabilityReport()) +} + +func TestDiscoverableScannerWithOneDiscoverableOpenAPI(t *testing.T) { + httpmock.Activate() + defer httpmock.DeactivateAndReset() + + securityScheme := auth.NewNoAuthSecurityScheme() + operation := request.NewOperation("http://localhost:8080/openapi.yaml", "GET", nil, nil, nil) + httpmock.RegisterResponder(operation.Method, operation.Url, httpmock.NewBytesResponder(204, nil).HeaderAdd(http.Header{"Server": []string{"Apache/2.4.29 (Ubuntu)"}})) + httpmock.RegisterNoResponder(func(req *http.Request) (*http.Response, error) { + return httpmock.NewStringResponse(404, "Not Found"), nil + }) + + expectedReport := report.VulnerabilityReport{ + SeverityLevel: discover.DiscoverableOpenAPISeverityLevel, + Name: discover.DiscoverableOpenAPIVulnerabilityName, + Description: discover.DiscoverableOpenAPIVulnerabilityDescription, + Operation: operation, + } + + report, err := discover.DiscoverableOpenAPIScanHandler(operation, securityScheme) + + require.NoError(t, err) + assert.Greater(t, httpmock.GetTotalCallCount(), 0) + assert.True(t, report.HasVulnerabilityReport()) + assert.Equal(t, report.GetVulnerabilityReports()[0].Name, expectedReport.Name) + assert.Equal(t, report.GetVulnerabilityReports()[0].Operation.Url, expectedReport.Operation.Url) +} diff --git a/scan/best_practices/server_signature.go b/scan/discover/server_signature.go similarity index 67% rename from scan/best_practices/server_signature.go rename to scan/discover/server_signature.go index dbc2505..def771a 100644 --- a/scan/best_practices/server_signature.go +++ b/scan/discover/server_signature.go @@ -1,4 +1,4 @@ -package bestpractices +package discover import ( "github.com/cerberauth/vulnapi/internal/auth" @@ -13,10 +13,10 @@ const ( ServerSignatureVulnerabilityDescription = "A Server signature is exposed in an header." ) -var SignatureHeaders = []string{"Server", "X-Powered-By", "X-AspNet-Version", "X-AspNetMvc-Version"} +var signatureHeaders = []string{"Server", "X-Powered-By", "X-AspNet-Version", "X-AspNetMvc-Version"} -func CheckSignatureHeader(operation *request.Operation, headers map[string][]string, r *report.ScanReport) bool { - for _, header := range SignatureHeaders { +func checkSignatureHeader(operation *request.Operation, headers map[string][]string, r *report.ScanReport) bool { + for _, header := range signatureHeaders { value := headers[header] if len(value) > 0 { r.AddVulnerabilityReport(&report.VulnerabilityReport{ @@ -33,11 +33,11 @@ func CheckSignatureHeader(operation *request.Operation, headers map[string][]str return true } -func ServerSignatureScanHandler(operation *request.Operation, ss auth.SecurityScheme) (*report.ScanReport, error) { +func ServerSignatureScanHandler(operation *request.Operation, securityScheme auth.SecurityScheme) (*report.ScanReport, error) { r := report.NewScanReport() - ss.SetAttackValue(ss.GetValidValue()) - vsa, err := scan.ScanURL(operation, &ss) + securityScheme.SetAttackValue(securityScheme.GetValidValue()) + vsa, err := scan.ScanURL(operation, &securityScheme) r.AddScanAttempt(vsa).End() if err != nil { return r, err @@ -47,7 +47,7 @@ func ServerSignatureScanHandler(operation *request.Operation, ss auth.SecuritySc return r, vsa.Err } - CheckSignatureHeader(operation, vsa.Response.Header, r) + checkSignatureHeader(operation, vsa.Response.Header, r) return r, nil } diff --git a/scan/best_practices/server_signature_test.go b/scan/discover/server_signature_test.go similarity index 77% rename from scan/best_practices/server_signature_test.go rename to scan/discover/server_signature_test.go index 15648b7..c5ef186 100644 --- a/scan/best_practices/server_signature_test.go +++ b/scan/discover/server_signature_test.go @@ -1,4 +1,4 @@ -package bestpractices_test +package discover_test import ( "net/http" @@ -7,7 +7,7 @@ import ( "github.com/cerberauth/vulnapi/internal/auth" "github.com/cerberauth/vulnapi/internal/request" "github.com/cerberauth/vulnapi/report" - bestpractices "github.com/cerberauth/vulnapi/scan/best_practices" + "github.com/cerberauth/vulnapi/scan/discover" "github.com/jarcoal/httpmock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -21,15 +21,15 @@ func TestCheckSignatureHeaderWithSignatureHeader(t *testing.T) { securityScheme := auth.NewAuthorizationBearerSecurityScheme("default", &token) operation := request.NewOperation("http://localhost:8080/", "GET", nil, nil, nil) vulnerabilityReport := report.VulnerabilityReport{ - SeverityLevel: bestpractices.ServerSignatureSeverityLevel, - Name: bestpractices.ServerSignatureVulnerabilityName, - Description: bestpractices.ServerSignatureVulnerabilityDescription, + SeverityLevel: discover.ServerSignatureSeverityLevel, + Name: discover.ServerSignatureVulnerabilityName, + Description: discover.ServerSignatureVulnerabilityDescription, Operation: operation, } httpmock.RegisterResponder(operation.Method, operation.Url, httpmock.NewBytesResponder(204, nil).HeaderAdd(http.Header{"Server": []string{"Apache/2.4.29 (Ubuntu)"}})) - report, err := bestpractices.ServerSignatureScanHandler(operation, securityScheme) + report, err := discover.ServerSignatureScanHandler(operation, securityScheme) require.NoError(t, err) assert.Equal(t, 1, httpmock.GetTotalCallCount()) @@ -47,7 +47,7 @@ func TestCheckSignatureHeaderWithoutSignatureHeader(t *testing.T) { httpmock.RegisterResponder(operation.Method, operation.Url, httpmock.NewBytesResponder(204, nil)) - report, err := bestpractices.ServerSignatureScanHandler(operation, securityScheme) + report, err := discover.ServerSignatureScanHandler(operation, securityScheme) require.NoError(t, err) assert.Equal(t, 1, httpmock.GetTotalCallCount()) diff --git a/scan/scan.go b/scan/scan.go index 661a0ec..c7520fa 100644 --- a/scan/scan.go +++ b/scan/scan.go @@ -13,8 +13,10 @@ type ScanHandler func(o *request.Operation, ss auth.SecurityScheme) (*report.Sca type Scan struct { Operations request.Operations - Handlers []ScanHandler Reporter *report.Reporter + + OperationHandlers []ScanHandler + Handlers []ScanHandler } func NewScan(operations request.Operations, reporter *report.Reporter) (*Scan, error) { @@ -33,35 +35,22 @@ func NewScan(operations request.Operations, reporter *report.Reporter) (*Scan, e }, nil } -func (s *Scan) AddScanHandler(sh ScanHandler) *Scan { - s.Handlers = append(s.Handlers, sh) +func (s *Scan) AddOperationScanHandler(handler ScanHandler) *Scan { + s.OperationHandlers = append(s.OperationHandlers, handler) return s } -func (s *Scan) Execute() (*report.Reporter, []error, error) { - if err := s.ValidateOperation(s.Operations[0]); err != nil { - return nil, nil, err - } - - var errors []error - for _, operation := range s.Operations { - opErrors, opError := s.ExecuteOperation(operation) - if opError != nil { - return nil, nil, opError - } +func (s *Scan) AddScanHandler(handler ScanHandler) *Scan { + s.Handlers = append(s.Handlers, handler) - errors = append(errors, opErrors...) - } - - return s.Reporter, errors, nil + return s } -func (s *Scan) ExecuteOperation(operation *request.Operation) ([]error, error) { +func (s *Scan) ExecuteOperation(operation *request.Operation, handlers []ScanHandler) ([]error, error) { var errors []error - for _, handler := range s.Handlers { + for _, handler := range handlers { report, err := handler(operation, operation.SecuritySchemes[0]) // TODO: handle multiple security schemes - if err != nil { errors = append(errors, err) } @@ -72,6 +61,29 @@ func (s *Scan) ExecuteOperation(operation *request.Operation) ([]error, error) { return errors, nil } +func (s *Scan) Execute() (*report.Reporter, []error, error) { + operation := s.Operations[0] + if err := s.ValidateOperation(operation); err != nil { + return nil, nil, err + } + + errors, err := s.ExecuteOperation(operation, s.Handlers) + if err != nil { + return nil, nil, err + } + + for _, operation := range s.Operations { + opErrors, opError := s.ExecuteOperation(operation, s.OperationHandlers) + if opError != nil { + return nil, nil, opError + } + + errors = append(errors, opErrors...) + } + + return s.Reporter, errors, nil +} + func (s *Scan) ValidateOperation(operation *request.Operation) error { attempt, err := scan.ScanURL(operation, &operation.SecuritySchemes[0]) if err != nil { @@ -84,3 +96,7 @@ func (s *Scan) ValidateOperation(operation *request.Operation) error { return nil } + +func (s *Scan) WithAllScans() *Scan { + return s.WithAllVulnsScans().WithAllBestPracticesScans().WithAllDiscoverScans() +} diff --git a/scan/vulns.go b/scan/vulns.go index 8b0458f..a74fc1c 100644 --- a/scan/vulns.go +++ b/scan/vulns.go @@ -3,19 +3,19 @@ package scan import "github.com/cerberauth/vulnapi/scan/jwt" func (s *Scan) WithAlgNoneJwtScan() *Scan { - return s.AddScanHandler(jwt.AlgNoneJwtScanHandler) + return s.AddOperationScanHandler(jwt.AlgNoneJwtScanHandler) } func (s *Scan) WithNotVerifiedJwtScan() *Scan { - return s.AddScanHandler(jwt.NotVerifiedScanHandler) + return s.AddOperationScanHandler(jwt.NotVerifiedScanHandler) } func (s *Scan) WithJWTNullSignatureScan() *Scan { - return s.AddScanHandler(jwt.NullSignatureScanHandler) + return s.AddOperationScanHandler(jwt.NullSignatureScanHandler) } func (s *Scan) WithWeakJwtSecretScan() *Scan { - return s.AddScanHandler(jwt.BlankSecretScanHandler).AddScanHandler(jwt.DictSecretScanHandler) + return s.AddOperationScanHandler(jwt.BlankSecretScanHandler).AddOperationScanHandler(jwt.DictSecretScanHandler) } func (s *Scan) WithAllVulnsScans() *Scan {