diff --git a/README.md b/README.md index 32a0429..16195ae 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,11 @@ The VulnAPI may support additional options for customizing scans or output forma This scanner is provided for educational and informational purposes only. It should not be used for malicious purposes or to attack any system without proper authorization. Always respect the security and privacy of others. +## Thanks + +This project used the following open-source libraries: +* [SecLists](https://github.com/danielmiessler/SecLists) + ## License This repository is licensed under the [MIT License](https://github.com/cerberauth/vulnapi/blob/main/LICENSE) @ [CerberAuth](https://www.cerberauth.com/). You are free to use, modify, and distribute the contents of this repository for educational and testing purposes. diff --git a/report/vuln.go b/report/vuln.go index d8bf55d..1ae84fa 100644 --- a/report/vuln.go +++ b/report/vuln.go @@ -14,6 +14,12 @@ type VulnerabilityReport struct { Operation *request.Operation } +func (vr *VulnerabilityReport) WithOperation(operation *request.Operation) *VulnerabilityReport { + vr.Operation = operation + + return vr +} + func (vr *VulnerabilityReport) IsLowRiskSeverity() bool { return vr.SeverityLevel < 4 } diff --git a/report/vuln_test.go b/report/vuln_test.go index 59b7b39..a659ae6 100644 --- a/report/vuln_test.go +++ b/report/vuln_test.go @@ -10,6 +10,20 @@ import ( "github.com/stretchr/testify/assert" ) +func TestVulnerabilityReport_WithOperation(t *testing.T) { + vr := &report.VulnerabilityReport{} + operation := &request.Operation{ + Request: &http.Request{ + Method: "POST", + URL: &url.URL{Scheme: "https", Host: "example.com", Path: "/vulnerability"}, + }, + } + + vr.WithOperation(operation) + + assert.Equal(t, operation, vr.Operation) +} + func TestVulnerabilityReport_IsLowRiskSeverity(t *testing.T) { vr := &report.VulnerabilityReport{SeverityLevel: 3.5} assert.True(t, vr.IsLowRiskSeverity()) @@ -41,6 +55,7 @@ func TestVulnerabilityReport_String(t *testing.T) { expected := "[High][Test Vulnerability] GET https://example.com/vulnerability: This is a test vulnerability" assert.Equal(t, expected, vr.String()) } + func TestVulnerabilityReport_SeverityLevelString(t *testing.T) { vr := &report.VulnerabilityReport{} diff --git a/scan/discover.go b/scan/discover.go index 4d6eaf6..5ac4367 100644 --- a/scan/discover.go +++ b/scan/discover.go @@ -10,10 +10,14 @@ func (s *Scan) WithDiscoverableOpenAPIScan() *Scan { return s.AddScanHandler(discover.DiscoverableOpenAPIScanHandler) } +func (s *Scan) WithDiscoverableGraphQLPathScan() *Scan { + return s.AddScanHandler(discover.DiscoverableGraphQLPathScanHandler) +} + func (s *Scan) WithGraphQLIntrospectionScan() *Scan { return s.AddScanHandler(discover.GraphqlIntrospectionScanHandler) } func (s *Scan) WithAllDiscoverScans() *Scan { - return s.WithServerSignatureScan().WithDiscoverableOpenAPIScan().WithGraphQLIntrospectionScan() + return s.WithServerSignatureScan().WithDiscoverableOpenAPIScan().WithDiscoverableGraphQLPathScan().WithGraphQLIntrospectionScan() } diff --git a/scan/discover/discoverable_openapi.go b/scan/discover/discoverable_openapi.go index 9c7709c..f19e8c5 100644 --- a/scan/discover/discoverable_openapi.go +++ b/scan/discover/discoverable_openapi.go @@ -1,12 +1,8 @@ package discover import ( - "net/http" - "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" ) @@ -29,37 +25,15 @@ var potentialOpenAPIPaths = []string{ "/v1/api-docs", "/v2/api-docs", "/v3/api-docs", - ".well-known/openapi.json", - ".well-known/openapi.yaml", } +var openapiSeclistUrl = "https://raw.githubusercontent.com/danielmiessler/SecLists/master/Discovery/Web-Content/swagger.txt" func DiscoverableOpenAPIScanHandler(operation *request.Operation, securityScheme auth.SecurityScheme) (*report.ScanReport, error) { - r := report.NewScanReport() - - securityScheme.SetAttackValue(securityScheme.GetValidValue()) - - base := ExtractBaseURL(operation.Request.URL) - for _, path := range potentialOpenAPIPaths { - newRequest, _ := http.NewRequest(http.MethodGet, base.ResolveReference(&url.URL{Path: path}).String(), nil) - newOperation := request.NewOperationFromRequest(newRequest, []auth.SecurityScheme{securityScheme}) - - 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 - } - } + handler := CreateURLScanHandler("OpenAPI", openapiSeclistUrl, potentialOpenAPIPaths, &report.VulnerabilityReport{ + SeverityLevel: DiscoverableOpenAPISeverityLevel, + Name: DiscoverableOpenAPIVulnerabilityName, + Description: DiscoverableOpenAPIVulnerabilityDescription, + }) - return r, nil + return handler(operation, securityScheme) } diff --git a/scan/discover/graphql.go b/scan/discover/graphql.go index fd8323d..f2b5aa3 100644 --- a/scan/discover/graphql.go +++ b/scan/discover/graphql.go @@ -12,6 +12,10 @@ import ( ) const ( + DiscoverableGraphQLPathSeverityLevel = 0 + DiscoverableGraphQLPathVulnerabilityName = "Discoverable GraphQL Path" + DiscoverableGraphQLPathVulnerabilityDescription = "GraphQL path seems discoverable and can lead to information disclosure and security issues" + GraphqlIntrospectionEnabledSeverityLevel = 0 GraphqlIntrospectionEnabledVulnerabilityName = "GraphQL Introspection enabled" GraphqlIntrospectionEnabledVulnerabilityDescription = "GraphQL Introspection seems enabled and can lead to information disclosure and security issues" @@ -26,6 +30,7 @@ var potentialGraphQLEndpoints = []string{ "/v1/graphiql", "/v1/explorer", } +var graphqlSeclistUrl = "https://raw.githubusercontent.com/danielmiessler/SecLists/master/Discovery/Web-Content/graphql.txt" func newGraphqlIntrospectionRequest(endpoint *url.URL) (*http.Request, error) { return http.NewRequest(http.MethodPost, endpoint.String(), bytes.NewReader([]byte(`{ @@ -68,3 +73,13 @@ func GraphqlIntrospectionScanHandler(operation *request.Operation, securitySchem return r, nil } + +func DiscoverableGraphQLPathScanHandler(operation *request.Operation, securityScheme auth.SecurityScheme) (*report.ScanReport, error) { + handler := CreateURLScanHandler("GraphQL", graphqlSeclistUrl, potentialGraphQLEndpoints, &report.VulnerabilityReport{ + SeverityLevel: DiscoverableGraphQLPathSeverityLevel, + Name: DiscoverableGraphQLPathVulnerabilityName, + Description: DiscoverableGraphQLPathVulnerabilityDescription, + }) + + return handler(operation, securityScheme) +} diff --git a/scan/discover/graphql_test.go b/scan/discover/graphql_test.go index 3c01211..40f5857 100644 --- a/scan/discover/graphql_test.go +++ b/scan/discover/graphql_test.go @@ -58,3 +58,49 @@ func TestGraphqlIntrospectionScanHandlerWithKnownGraphQLIntrospectionEndpoint(t assert.Equal(t, report.GetVulnerabilityReports()[0].Name, expectedReport.Name) assert.Equal(t, report.GetVulnerabilityReports()[0].Operation.Request.URL.String(), expectedReport.Operation.Request.URL.String()) } + +func TestDiscoverableScannerWithNoDiscoverableGraphqlPath(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.Request.URL.String(), 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.DiscoverableGraphQLPathScanHandler(operation, securityScheme) + + require.NoError(t, err) + assert.Greater(t, httpmock.GetTotalCallCount(), 7) + assert.False(t, report.HasVulnerabilityReport()) +} + +func TestDiscoverableScannerWithOneDiscoverableGraphQLPath(t *testing.T) { + httpmock.Activate() + defer httpmock.DeactivateAndReset() + + securityScheme := auth.NewNoAuthSecurityScheme() + operation := request.NewOperation("http://localhost:8080/graphql", "GET", nil, nil, nil) + httpmock.RegisterResponder(operation.Method, operation.Request.URL.String(), 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.DiscoverableGraphQLPathSeverityLevel, + Name: discover.DiscoverableGraphQLPathVulnerabilityName, + Description: discover.DiscoverableGraphQLPathVulnerabilityDescription, + Operation: operation, + } + + report, err := discover.DiscoverableGraphQLPathScanHandler(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.Request.URL.String(), expectedReport.Operation.Request.URL.String()) +} diff --git a/scan/discover/utils.go b/scan/discover/utils.go index ad7fc6a..2b28f2b 100644 --- a/scan/discover/utils.go +++ b/scan/discover/utils.go @@ -1,7 +1,14 @@ package discover import ( + "net/http" "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" + "github.com/cerberauth/vulnapi/seclist" ) func ExtractBaseURL(inputURL *url.URL) *url.URL { @@ -12,3 +19,35 @@ func ExtractBaseURL(inputURL *url.URL) *url.URL { return baseURL } + +func CreateURLScanHandler(name string, seclistUrl string, defaultUrls []string, vulnReport *report.VulnerabilityReport) func(operation *request.Operation, securityScheme auth.SecurityScheme) (*report.ScanReport, error) { + scanUrls := defaultUrls + if urlsFromSeclist, err := seclist.NewSecListFromURL(name, seclistUrl); err == nil { + scanUrls = urlsFromSeclist.Items + } + + return func(operation *request.Operation, securityScheme auth.SecurityScheme) (*report.ScanReport, error) { + r := report.NewScanReport() + securityScheme.SetAttackValue(securityScheme.GetValidValue()) + + base := ExtractBaseURL(operation.Request.URL) + for _, path := range scanUrls { + newRequest, _ := http.NewRequest(http.MethodGet, base.ResolveReference(&url.URL{Path: path}).String(), nil) + newOperation := request.NewOperationFromRequest(newRequest, []auth.SecurityScheme{securityScheme}) + + attempt, err := scan.ScanURL(newOperation, &securityScheme) + r.AddScanAttempt(attempt).End() + if err != nil { + return r, err + } + + if attempt.Response.StatusCode < 300 { + r.AddVulnerabilityReport(vulnReport.WithOperation(newOperation)) + + return r, nil + } + } + + return r, nil + } +} diff --git a/seclist/seclist.go b/seclist/seclist.go new file mode 100644 index 0000000..f16aa5a --- /dev/null +++ b/seclist/seclist.go @@ -0,0 +1,95 @@ +package seclist + +import ( + "bufio" + "errors" + "io" + "net/http" + "os" +) + +type SecList struct { + Name string + Items []string +} + +func NewSecList(name string) *SecList { + return &SecList{ + Name: name, + Items: []string{}, + } +} + +func NewSecListFromFile(name, filepath string) (*SecList, error) { + s := NewSecList(name) + err := s.ImportFromFile(filepath) + if err != nil { + return nil, err + } + return s, nil +} + +func NewSecListFromURL(name, url string) (*SecList, error) { + s := NewSecList(name) + err := s.DownloadFromURL(url) + if err != nil { + return nil, err + } + return s, nil +} + +func (s *SecList) ImportFromFile(filepath string) error { + file, err := os.Open(filepath) + if err != nil { + return err + } + defer file.Close() + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := scanner.Text() + s.Items = append(s.Items, line) + } + + if err := scanner.Err(); err != nil { + return err + } + + return nil +} + +func (s *SecList) DownloadFromURL(url string) error { + resp, err := http.Get(url) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return errors.New("sec list download failed") + } + + tempFile, err := os.CreateTemp("", "seclist") + if err != nil { + return err + } + defer tempFile.Close() + + _, err = io.Copy(tempFile, resp.Body) + if err != nil { + return err + } + + filepath := tempFile.Name() + err = s.ImportFromFile(filepath) + if err != nil { + return err + } + + err = os.Remove(filepath) + if err != nil { + return err + } + + return nil +} diff --git a/seclist/seclist_test.go b/seclist/seclist_test.go new file mode 100644 index 0000000..3dc4d86 --- /dev/null +++ b/seclist/seclist_test.go @@ -0,0 +1,57 @@ +package seclist_test + +import ( + "io" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/cerberauth/vulnapi/seclist" + "github.com/stretchr/testify/assert" +) + +func TestNewSecListFromFile(t *testing.T) { + file := "line 1\nline 2\nline 3\n" + f, err := os.CreateTemp("", "seclist") + assert.NoError(t, err) + defer os.Remove(f.Name()) + + io.WriteString(f, file) + + seclist, err := seclist.NewSecListFromFile("seclist", f.Name()) + + assert.NoError(t, err) + assert.Equal(t, 3, len(seclist.Items)) + assert.Equal(t, "line 1", seclist.Items[0]) + assert.Equal(t, "line 2", seclist.Items[1]) + assert.Equal(t, "line 3", seclist.Items[2]) +} + +func TestNewSecListFromURL(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("line 1\n")) + w.Write([]byte("line 2\n")) + w.Write([]byte("line 3\n")) + })) + defer server.Close() + + seclist, err := seclist.NewSecListFromURL("seclist", server.URL) + + assert.NoError(t, err) + assert.Equal(t, 3, len(seclist.Items)) + assert.Equal(t, "line 1", seclist.Items[0]) + assert.Equal(t, "line 2", seclist.Items[1]) + assert.Equal(t, "line 3", seclist.Items[2]) +} + +func TestNewSecListFromURLWhenResponseNotOk(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + _, err := seclist.NewSecListFromURL("seclist", server.URL) + + assert.Error(t, err) +}