Skip to content

Commit

Permalink
Merge pull request #55 from cerberauth/discover-openapi
Browse files Browse the repository at this point in the history
feat: scan for discoverable openapi files
  • Loading branch information
emmanuelgautier authored Mar 6, 2024
2 parents 384f76c + 7cbdbca commit fa5024d
Show file tree
Hide file tree
Showing 13 changed files with 254 additions and 58 deletions.
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion cmd/scan/curl.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
},
Expand Down
2 changes: 1 addition & 1 deletion cmd/scan/openapi.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
},
Expand Down
16 changes: 11 additions & 5 deletions internal/request/operation.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
27 changes: 24 additions & 3 deletions internal/request/operation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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)
}
12 changes: 4 additions & 8 deletions scan/best_practices.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
11 changes: 11 additions & 0 deletions scan/discover.go
Original file line number Diff line number Diff line change
@@ -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()
}
83 changes: 83 additions & 0 deletions scan/discover/discoverable_openapi.go
Original file line number Diff line number Diff line change
@@ -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
}
60 changes: 60 additions & 0 deletions scan/discover/discoverable_openapi_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package bestpractices
package discover

import (
"github.com/cerberauth/vulnapi/internal/auth"
Expand All @@ -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{
Expand All @@ -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
Expand All @@ -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
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package bestpractices_test
package discover_test

import (
"net/http"
Expand All @@ -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"
Expand All @@ -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())
Expand All @@ -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())
Expand Down
Loading

0 comments on commit fa5024d

Please sign in to comment.