From 8bec6632190946fdb03c27d7c6c755f4cad4ae5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Carpintero?= Date: Tue, 14 Jan 2025 15:01:24 +0100 Subject: [PATCH 1/2] refactor zap check --- cmd/vulcan-zap/Dockerfile | 3 +- cmd/vulcan-zap/main.go | 637 ++++++++++++++++------------------- cmd/vulcan-zap/main_test.go | 117 +++++++ cmd/vulcan-zap/manifest.toml | 1 + cmd/vulcan-zap/zap.go | 144 -------- go.mod | 1 - go.sum | 2 - 7 files changed, 415 insertions(+), 490 deletions(-) create mode 100644 cmd/vulcan-zap/main_test.go delete mode 100644 cmd/vulcan-zap/zap.go diff --git a/cmd/vulcan-zap/Dockerfile b/cmd/vulcan-zap/Dockerfile index 6a26cb612..d363e3253 100644 --- a/cmd/vulcan-zap/Dockerfile +++ b/cmd/vulcan-zap/Dockerfile @@ -1,7 +1,8 @@ # Copyright 2019 Adevinta -FROM softwaresecurityproject/zap-bare:2.16.0 +FROM zaproxy/zap-bare:2.16.0 USER root + RUN chown -R zap /zap/ USER zap diff --git a/cmd/vulcan-zap/main.go b/cmd/vulcan-zap/main.go index 2c3204993..cf5b1a051 100644 --- a/cmd/vulcan-zap/main.go +++ b/cmd/vulcan-zap/main.go @@ -7,42 +7,38 @@ package main import ( "context" "encoding/json" - "errors" "fmt" - "net" "net/url" - "os/exec" + "os" "sort" "strconv" "strings" - "time" + "sync" + "text/template" check "github.com/adevinta/vulcan-check-sdk" "github.com/adevinta/vulcan-check-sdk/helpers" + "github.com/adevinta/vulcan-check-sdk/helpers/command" checkstate "github.com/adevinta/vulcan-check-sdk/state" report "github.com/adevinta/vulcan-report" "github.com/sirupsen/logrus" - "github.com/zaproxy/zap-api-go/zap" ) -var ( - checkName = "vulcan-zap" - logger = check.NewCheckLog(checkName) - client zap.Interface +const ( + checkName = "vulcan-zap" + contextName = "target" + userAgent = "Vulcan - Security Scanner" ) -const contextName = "target" - type options struct { Depth int `json:"depth"` Active bool `json:"active"` Port int `json:"port"` - Username string `json:"username"` - Password string `json:"password"` MinScore float32 `json:"min_score"` // List of active/passive scanners to disable by their identifiers: // https://www.zaproxy.org/docs/alerts/ DisabledScanners []string `json:"disabled_scanners"` + DisabledActiveScanners []string `json:"disabled_active_scanners"` IgnoredFingerprintScanners []string `json:"ignored_fingerprint_scanners"` MaxSpiderDuration int `json:"max_spider_duration"` MaxScanDuration int `json:"max_scan_duration"` // In minutes @@ -51,8 +47,134 @@ type options struct { OpenapiHost string `json:"openapi_host"` } +type tmplOptions struct { + Opts options + URL string + IncludePath string + Dir string + UserAgent string +} + +const configTemplate = ` +env: + contexts: + - name: Default Context + urls: [ "{{ .URL }}" ] + includePaths: [ "{{ .IncludePath }}" ] +jobs: +{{ if .Opts.OpenapiUrl }} +- type: openapi + parameters: + apiUrl: "{{ .Opts.OpenapiUrl }}" + targetUrl: "{{ .Opts.OpenapiHost }}" +{{ end }} +- type: passiveScan-config + parameters: + scanOnlyInScope: true + maxAlertsPerRule: 5 + enableTags: false + rules: +{{ range .Opts.DisabledScanners }} + - id: {{ . }} + threshold: off +{{ end }} +- type: spider + parameters: + maxDuration: {{ .Opts.MaxSpiderDuration }} + maxDepth: {{ .Opts.Depth }} + userAgent: "{{ .UserAgent }}" +- type: spiderAjax + parameters: + maxDuration: {{ .Opts.MaxSpiderDuration }} + maxCrawlDepth: {{ .Opts.Depth }} + browserId: htmlunit +- type: passiveScan-wait + parameters: {} +{{ if .Opts.Active }} +- type: activeScan + parameters: + maxRuleDurationInMins: {{ .Opts.MaxRuleDuration }} + maxScanDurationInMins: {{ .Opts.MaxScanDuration }} + maxAlertsPerRule: 5 + policyDefinition: + defaultStrength: medium + defaultThreshold: medium + rules: +{{ range .Opts.DisabledActiveScanners }} + - id: {{ . }} + threshold: off + strength: default +{{ end }} +{{ end }} +- name: report.json + type: report + parameters: + template: traditional-json + reportDir: "{{ .Dir }}" + reportFile: report.json + displayReport: false + risks: + - info + - low + - medium + - high + confidences: + - falsepositive + - low + - medium + - high + - confirmed +` + +type Report struct { + ProgramName string `json:"@programName"` + Version string `json:"@version"` + Generated string `json:"@generated"` + Site []struct { + Name string `json:"@name"` + Host string `json:"@host"` + Port string `json:"@port"` + Ssl string `json:"@ssl"` + Alerts []struct { + Pluginid string `json:"pluginid"` + AlertRef string `json:"alertRef"` + Alert string `json:"alert"` + Name string `json:"name"` + Riskcode string `json:"riskcode"` + Confidence string `json:"confidence"` + Riskdesc string `json:"riskdesc"` + Desc string `json:"desc"` + Instances []struct { + URI string `json:"uri"` + Method string `json:"method"` + Param string `json:"param"` + Attack string `json:"attack"` + Evidence string `json:"evidence"` + Otherinfo string `json:"otherinfo"` + } `json:"instances"` + Count string `json:"count"` + Solution string `json:"solution"` + Otherinfo string `json:"otherinfo"` + Reference string `json:"reference"` + Cweid string `json:"cweid"` + Wascid string `json:"wascid"` + Sourceid string `json:"sourceid"` + } `json:"alerts"` + } `json:"site"` +} + +type pool struct { + l []int + m sync.Mutex +} + +// Create a pool of 5 ports starting from 13000. +// 5 defines the max number of concurrent scans to allow before a "too many requests error" +var portPool *pool = createPortPool(13000, 5) + func main() { - run := func(_ context.Context, target, assetType, optJSON string, state checkstate.State) (err error) { + run := func(ctx context.Context, target, assetType, optJSON string, state checkstate.State) (err error) { + logger := check.NewCheckLogFromContext(ctx, checkName) var opt options if optJSON != "" { if err = json.Unmarshal([]byte(optJSON), &opt); err != nil { @@ -60,8 +182,6 @@ func main() { } } - disabledScanners := strings.Join(opt.DisabledScanners, ",") - isReachable, err := helpers.IsReachable(target, assetType, nil) if err != nil { logger.Warnf("Can not check asset reachability: %v", err) @@ -70,299 +190,176 @@ func main() { return checkstate.ErrAssetUnreachable } - ctx, ctxCancel := context.WithCancel(context.Background()) - - // Execute ZAP daemon. - go func() { - logger.Print("Executing for ZAP daemon...") - out, err := exec.Command( - "/zap/zap.sh", - "-daemon", "-host", "127.0.0.1", "-port", "8080", - "-config", "api.disablekey=true", - "-config", "database.recoverylog=false", // Reduce disk usage - "-notel", // Disables telemetry - "-silent", // Prevents from checking for addon updates - ).Output() - - logger.Debugf("Error executing ZAP daemon: %v", err) - logger.Debugf("Output of the ZAP daemon: %s", string(out)) - - ctxCancel() - }() - - // Wait for ZAP to be available. - logger.Print("Waiting for ZAP proxy...") - ticker := time.NewTicker(time.Second) - proxyLoop: - for { - select { - case <-ctx.Done(): - return errors.New("ZAP exited while waiting for proxy") - case <-ticker.C: - conn, _ := net.DialTimeout("tcp", "127.0.0.1:8080", time.Second) - if conn != nil { - conn.Close() - break proxyLoop - } - } - } - - logger.Print("Initiating ZAP client...") - - cfg := &zap.Config{ - Proxy: "http://127.0.0.1:8080", - Base: "http://127.0.0.1:8080/JSON/", - BaseOther: "http://127.0.0.1:8080/OTHER/", - } - client, err = zap.NewClient(cfg) - if err != nil { - return fmt.Errorf("error configuring the ZAP proxy client: %w", err) - } - - client.Core().SetOptionDefaultUserAgent("Vulcan - Security Scanner - vulcan@adevinta.com") - targetURL, err := url.Parse(target) if err != nil { return fmt.Errorf("error parsing target URL: %w", err) } - cx, err := client.Context().NewContext(contextName) - if err != nil { - return fmt.Errorf("error creating scope context: %w", err) - } - contextID, err := getStringAttribute(cx, "contextId") - if err != nil { - return err - } - // Add base URL to the scope. targetPort := "" if targetURL.Port() != "" { targetPort = fmt.Sprintf(":%s", targetURL.Port()) } - hostnameRegExQuote := strings.Replace(targetURL.Hostname(), `.`, `\.`, -1) - includeInContextRegEx := fmt.Sprintf(`http(s)?:\/\/%s%s\/.*`, hostnameRegExQuote, targetPort) - logger.Printf("include in context regexp: %s", includeInContextRegEx) - _, err = client.Context().IncludeInContext(contextName, includeInContextRegEx) - if err != nil { - return fmt.Errorf("error including target URL to context: %w", err) - } + includePathRegex := fmt.Sprintf(`http(s)?://%s%s/.*`, strings.Replace(targetURL.Hostname(), `.`, `\\.`, -1), targetPort) - _, err = client.Context().SetContextInScope(contextName, "True") + tempDir, err := os.MkdirTemp(os.TempDir(), "zap-") if err != nil { - return fmt.Errorf("error setting context in scope: %w", err) - } - - if opt.Username != "" { - auth := client.Authentication() - auth.SetAuthenticationMethod("1", "httpAuthentication", fmt.Sprintf("hostname=%v&port=%v", targetURL.Hostname(), targetURL.Port())) - - users := client.Users() - users.NewUser("1", opt.Username) - users.SetAuthenticationCredentials("1", "0", fmt.Sprintf("username=%v&password=%v", opt.Username, opt.Password)) - users.SetUserEnabled("1", "0", "True") - } - - if opt.OpenapiUrl != "" { - _, err = client.Openapi().ImportUrl(opt.OpenapiUrl, opt.OpenapiHost, contextID) - if err != nil { - return fmt.Errorf("error importing openapi url: %w", err) - } + return fmt.Errorf("unable to create tmp dir: %w", err) } - _, err = client.Pscan().DisableScanners(disabledScanners) - if err != nil { - return fmt.Errorf("error disabling scanners for passive scan: %w", err) - } + defer os.RemoveAll(tempDir) - _, err = client.Spider().SetOptionMaxDepth(opt.Depth) - if err != nil { - return fmt.Errorf("error setting spider max depth: %w", err) - } - _, err = client.Spider().SetOptionMaxDuration(opt.MaxSpiderDuration) + tmpl, err := template.New("config").Parse(configTemplate) if err != nil { - return fmt.Errorf("error setting spider max duration: %w", err) - } - - _, err = client.Pscan().DisableAllTags() + panic(err) + } + sb := new(strings.Builder) + err = tmpl.Execute(sb, tmplOptions{ + Opts: opt, + URL: targetURL.String(), + Dir: tempDir, + IncludePath: includePathRegex, + UserAgent: userAgent, + }) if err != nil { - return fmt.Errorf("error disabling all tags: %w", err) + return fmt.Errorf("unable to execute template: %w", err) } - logger.Printf("Running spider %v levels deep, max duration %v, ...", opt.Depth, opt.MaxSpiderDuration) + file := fmt.Sprintf("%s/auto.yaml", tempDir) - resp, err := client.Spider().Scan(targetURL.String(), "", contextName, "", "") + err = os.WriteFile(file, []byte(sb.String()), 0600) if err != nil { - return fmt.Errorf("error executing the spider: %w", err) - } - - v, ok := resp["scan"] - if !ok { - // Scan has not been executed. Due to the ZAP proxy behaviour - // (the request to the ZAP API does not return the status codes) - // we can not be sure whether it was because a non existant target - // or because an error accessing the ZAP API. Therefore, we will - // terminate the check without errors. - logger.WithFields(logrus.Fields{"resp": resp}).Warn("Scan not present in response body when calling Spider().Scan()") - return nil - } - - scanid, ok := v.(string) - if !ok { - return errors.New("scan is present in response body when calling Spider().Scan() but it is not a string") + return fmt.Errorf("unable to create auto.yaml file: %w", err) } - ticker = time.NewTicker(10 * time.Second) - spiderLoop: - for { - select { - case <-ctx.Done(): - return errors.New("ZAP exited while waiting for spider") - case <-ticker.C: - resp, err := client.Spider().Status(scanid) - if err != nil { - return fmt.Errorf("error getting the status of the spider: %w", err) - } - v, ok := resp["status"] - if !ok { - // In this case if we can not get the status let's fail. - return fmt.Errorf("cannot retrieve the status of the spider: %v", resp) - } - status, ok := v.(string) - if !ok { - return errors.New("status is present in response body when calling Spider().Scatus() but it is not a string") - } - - progress, err := strconv.Atoi(status) - if err != nil { - return fmt.Errorf("can not convert status value %s into an int", status) - } + logger.WithField("autorun", sb.String()).Info("cmd config") - logger.Debugf("Spider at %v progress.", progress) - - if opt.Active { - state.SetProgress(float32(progress) / 200) - } else { - state.SetProgress(float32(progress) / 100) - } - - if progress >= 100 { - break spiderLoop - } - } - } - - logger.Print("Waiting for spider results...") - time.Sleep(5 * time.Second) - - resp, err = client.Spider().AllUrls() - if err != nil { - return fmt.Errorf("error getting the list of URLs from spider: %w", err) - } - logger.Printf("Spider found the following URLs: %+v", resp) - - _, err = client.AjaxSpider().SetOptionMaxDuration(opt.MaxSpiderDuration) + zapPort, err := portPool.getPort() if err != nil { - return fmt.Errorf("error setting ajax spider max duration: %w", err) - } - - logger.Printf("Running AJAX spider %v levels deep, max duration %v...", opt.Depth, opt.MaxSpiderDuration) - - client.AjaxSpider().SetOptionMaxCrawlDepth(opt.Depth) - _, err = client.AjaxSpider().Scan(targetURL.String(), "", contextName, "") + return fmt.Errorf("too many requests: %w", err) + } + defer portPool.releasePort(zapPort) + + out, outErr, exitCode, err := command.ExecuteWithStdErr(ctx, logger, + "/zap/zap.sh", + "-dir", tempDir, + "-cmd", "-autorun", file, + "-config", "database.recoverylog=false", // Reduce disk usage + "-config", fmt.Sprintf("connection.defaultUserAgent='%s'", userAgent), + "-port", strconv.Itoa(zapPort), + "-notel", // Disables telemetry + "-silent", // Prevents from checking for addon updates + ) if err != nil { - return fmt.Errorf("error executing the AJAX spider: %w", err) + logger.Errorf("Output of the ZAP daemon: %s", string(out)) + return fmt.Errorf("running zap: %w", err) } - ticker = time.NewTicker(10 * time.Second) - ajaxSpiderLoop: - for { - select { - case <-ctx.Done(): - return errors.New("ZAP exited while waiting for AJAX spider") - case <-ticker.C: - resp, err := client.AjaxSpider().Status() - if err != nil { - return fmt.Errorf("error getting the status of the AJAX spider: %w", err) - } - - v, ok := resp["status"] - if !ok { - // In this case if we can not get the status let's fail. - return errors.New("can not retrieve the status of the AJAX spider") - } - status, ok := v.(string) - if !ok { - return errors.New("status is present in response body when calling AjaxSpider().Scatus() but it is not a string") - } - - if status >= "running" { - break ajaxSpiderLoop - } - } - } - - logger.Print("Waiting for AJAX spider results...") - time.Sleep(5 * time.Second) - - resp, err = client.AjaxSpider().FullResults() - if err != nil { - return fmt.Errorf("error getting the list of URLs from AJAX spider: %w", err) + select { + case <-ctx.Done(): + logger.Warn("Context canceled or deadline exceeded") + return ctx.Err() + default: } - logger.Printf("AJAX spider found the following URLs: %+v", resp) - // Scan actively only if explicitly indicated. - if opt.Active { - logger.Print("Running active scan...") - err := activeScan(ctx, targetURL, state, disabledScanners, opt.MaxScanDuration, opt.MaxRuleDuration, contextID) - if err != nil { - return err - } - logger.Print("Waiting for active scan results...") - time.Sleep(5 * time.Second) + // Exit codes: + // 0: Success + // 1: At least 1 FAIL + // 2: At least one WARN and no FAILs + // 3: Any other failure + if exitCode != 0 { + logger.WithField("exitCode", exitCode).WithField("stdOut", string(out)).WithField("stdErr", string(outErr)).Info("Zap finished") } - // Retrieve alerts. - alerts, err := client.Core().Alerts("", "", "", "") + res, err := os.ReadFile(fmt.Sprintf("%s/report.json", tempDir)) if err != nil { - return fmt.Errorf("error retrieving alerts: %v", alerts) + return fmt.Errorf("unable to read report.json: %v", err) } - alertsSlice, ok := alerts["alerts"].([]interface{}) - if !ok { - return errors.New("alerts does not exist or it is not an array of interface{}") + r := Report{} + if err = json.Unmarshal(res, &r); err != nil { + return fmt.Errorf("unable to parse report: %v", err) } vulnerabilities := make(map[string]*report.Vulnerability) vulnSummary2PluginID := make(map[string]string) - for _, alert := range alertsSlice { - a, ok := alert.(map[string]interface{}) - if !ok { - return errors.New("alert it is not a map[string]interface{}") + for _, site := range r.Site { + logger.WithFields(logrus.Fields{ + "site.host": site.Host, + "site.num_alerts": len(site.Alerts)}).Info("alerts") + if !strings.Contains(target, site.Host) { + // This can happen i.e. when the openapi target url is other than the target. + // DOUBT: Filter? exclude? + logger.Warnf("Reporting alerts from an outside target %s %s", target, site.Host) } + for _, a := range site.Alerts { + + cwe := 0 + if a.Cweid != "-1" { + cwe, err = strconv.Atoi(a.Cweid) + if err != nil { + logger.Warnf("Wrong number Cweid %d", cwe) + } + } + v := report.Vulnerability{ + Summary: a.Name, + Description: trimP(a.Desc), + Details: a.Otherinfo, + Recommendations: splitP(a.Solution), + References: splitP(a.Reference), + Labels: []string{"issue", "web", "zap"}, + CWEID: uint32(cwe), + Score: func(risk string) float32 { + switch risk { + case "0": + return report.SeverityThresholdNone + case "1": + return report.SeverityThresholdLow + case "2": + return report.SeverityThresholdMedium + case "3": + return report.SeverityThresholdHigh + } + return float32(report.SeverityNone) + }(a.Riskcode), + } - v, err := processAlert(a) - if err != nil { - logger.WithError(err).Warn("can not process alert") - continue - } - pluginID, err := parsePluginID(a) - if err != nil { - logger.WithError(err).Warn("can not parse plugin ID") - continue - } - vulnSummary2PluginID[v.Summary] = pluginID - - if _, ok := vulnerabilities[v.Summary]; ok { - vulnerabilities[v.Summary].Resources[0].Rows = append( - vulnerabilities[v.Summary].Resources[0].Rows, - v.Resources[0].Rows..., - ) - } else { - vulnerabilities[v.Summary] = &v + // DOUBT: Only the fist instance? + if len(a.Instances) > 0 { + i := a.Instances[0] + v.Resources = []report.ResourcesGroup{ + { + Name: "Affected Requests", + Header: []string{ + "Method", + "URL", + "Parameter", + "Attack", + "Evidence", + }, + Rows: []map[string]string{ + { + "Method": i.Method, + "URL": i.URI, + "Parameter": i.Param, + "Attack": i.Attack, + "Evidence": i.Evidence, + }, + }, + }, + } + } + vulnSummary2PluginID[v.Summary] = a.Pluginid + if _, ok := vulnerabilities[v.Summary]; ok { + vulnerabilities[v.Summary].Resources[0].Rows = append( + vulnerabilities[v.Summary].Resources[0].Rows, + v.Resources[0].Rows..., + ) + } else { + vulnerabilities[v.Summary] = &v + } } } - for _, v := range vulnerabilities { // NOTE: Due to a signifcant number of false positive findings // reported for low severity issues by ZAP, the MinScore option @@ -390,6 +387,14 @@ func main() { c.RunAndServe() } +func trimP(html string) string { + return strings.TrimSuffix(strings.TrimPrefix(html, "

"), "

") +} + +func splitP(html string) []string { + return strings.Split(trimP(html), "

") +} + func fingerprintFromResources(resources []map[string]string) string { var empty struct{} occurrences := []string{} @@ -423,74 +428,6 @@ func fingerprintFromResources(resources []map[string]string) string { return strings.Join(occurrences, "#") } -func activeScan(ctx context.Context, targetURL *url.URL, state checkstate.State, disabledScanners string, maxScanDuration, maxRuleDuration int, contextID string) error { - _, err := client.Ascan().DisableScanners(disabledScanners, "") - if err != nil { - return fmt.Errorf("error disabling scanners for active scan: %w", err) - } - - _, err = client.Ascan().SetOptionMaxScanDurationInMins(maxScanDuration) - if err != nil { - return fmt.Errorf("error setting max scan duration for active scan: %w", err) - } - - _, err = client.Ascan().SetOptionMaxRuleDurationInMins(maxRuleDuration) - if err != nil { - return fmt.Errorf("error setting max rule duration for active scan: %w", err) - } - - resp, err := client.Ascan().Scan("", "True", "", "", "", "", contextID) - if err != nil { - return fmt.Errorf("error executing the active scan: %w", err) - } - - v, ok := resp["scan"] - if !ok { - return fmt.Errorf("scan is not present in response body when calling Ascan().Scan()") - } - - scanid, ok := v.(string) - if !ok { - return errors.New("scan is present in response body when calling Ascan().Scan() but it is not a string") - } - - ticker := time.NewTicker(60 * time.Second) - for { - select { - case <-ctx.Done(): - return errors.New("ZAP exited while waiting for active scan") - case <-ticker.C: - ascan := client.Ascan() - - resp, err := ascan.Status(scanid) - if err != nil { - return fmt.Errorf("error getting the status of the scan: %w", err) - } - - v, ok := resp["status"] - if !ok { - // In this case if we can not get the status let's fail. - return errors.New("can not retrieve the status of the scan") - } - status, ok := v.(string) - if !ok { - return errors.New("status is present in response body when calling Ascan().Scatus() but it is not a string") - } - progress, err := strconv.Atoi(status) - if err != nil { - return fmt.Errorf("can not convert status value %s into an int", status) - } - - state.SetProgress((1 + float32(progress)) / 200) - - logger.Debugf("Active scan at %v progress.", progress) - if progress >= 100 { - return nil - } - } - } -} - func isPluginIgnoredForFingerprint(opt options, pluginID string) bool { for _, ignoredID := range opt.IgnoredFingerprintScanners { if pluginID == ignoredID { @@ -500,14 +437,30 @@ func isPluginIgnoredForFingerprint(opt options, pluginID string) bool { return false } -func getStringAttribute(m map[string]any, name string) (string, error) { - v, ok := m[name] - if !ok { - return "", fmt.Errorf("%s not found", name) +func createPortPool(first, count int) *pool { + pp := pool{ + l: make([]int, count), + m: sync.Mutex{}, } - str, ok := v.(string) - if !ok { - return "", fmt.Errorf("%s value [%v] is not a string", name, v) + for i := range pp.l { + pp.l[i] = first + i } - return str, nil + return &pp +} + +func (p *pool) getPort() (int, error) { + p.m.Lock() + defer p.m.Unlock() + if len(p.l) == 0 { + return 0, fmt.Errorf("no ports available") + } + port := p.l[len(p.l)-1] + p.l = p.l[:len(p.l)-1] + return port, nil +} + +func (p *pool) releasePort(port int) { + p.m.Lock() + defer p.m.Unlock() + p.l = append(p.l, port) } diff --git a/cmd/vulcan-zap/main_test.go b/cmd/vulcan-zap/main_test.go new file mode 100644 index 000000000..46041383d --- /dev/null +++ b/cmd/vulcan-zap/main_test.go @@ -0,0 +1,117 @@ +/* +Copyright 2025 Adevinta +*/ + +package main + +import ( + "strings" + "testing" + "text/template" +) + +func TestTemplate(t *testing.T) { + if strings.Contains(configTemplate, "\t") { + t.Errorf("template contains tabs: %s", strings.ReplaceAll(configTemplate, "\t", "***")) + } + + tmpl, err := template.New("config").Parse(configTemplate) + if err != nil { + panic(err) + } + + tests := []struct { + name string + opts tmplOptions + wantContains []string + wantNotContains []string + }{ + { + name: "Simple", + opts: tmplOptions{ + URL: "http://my.url", + Dir: "/tmp", + IncludePath: `http://my\.url/.*`, + }, + wantContains: []string{ + "maxDuration: 0", + `reportDir: "/tmp"`, + `r: "/tmp"`, + `urls: [ "http://my.url" ]`, + `includePaths: [ "http://my\.url/.*" ]`, + }, + wantNotContains: []string{ + "credentials:", + "type: openapi", + "type: activeScan", + "maxDuration: 10", + }, + }, + { + name: "Active", + opts: tmplOptions{ + Opts: options{ + Active: true, + }}, + wantContains: []string{ + "type: activeScan", + }, + }, + { + name: "Openapi", + opts: tmplOptions{ + Opts: options{OpenapiUrl: "http://my.url/openapi"}, + }, + wantContains: []string{ + "apiUrl: \"http://my.url/openapi\"", + }, + }, + { + name: "Constants", + opts: tmplOptions{ + Opts: options{ + Active: true, + MaxSpiderDuration: 10, + MaxRuleDuration: 100, + MaxScanDuration: 1000, + }, + }, + wantContains: []string{ + "maxDuration: 10", + "maxRuleDurationInMins: 100", + "maxScanDurationInMins: 1000", + }, + }, + { + name: "DisabledScanners", + opts: tmplOptions{ + Opts: options{ + DisabledScanners: []string{"S1", "123"}, + }, + }, + wantContains: []string{ + "- id: S1", + "- id: 123", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sb := new(strings.Builder) + err = tmpl.Execute(sb, tt.opts) + if err != nil { + t.Errorf("error: %v", err) + } + for _, sub := range tt.wantContains { + if !strings.Contains(sb.String(), sub) { + t.Errorf("not contains: %s vs %s", sb.String(), sub) + } + } + for _, sub := range tt.wantNotContains { + if strings.Contains(sb.String(), sub) { + t.Errorf("contains: %s vs %s", sb.String(), sub) + } + } + }) + } +} diff --git a/cmd/vulcan-zap/manifest.toml b/cmd/vulcan-zap/manifest.toml index 8a1629d80..cffbf8de4 100644 --- a/cmd/vulcan-zap/manifest.toml +++ b/cmd/vulcan-zap/manifest.toml @@ -16,6 +16,7 @@ Options = """{ "active": true, "min_score": 0, "disabled_scanners": ["10062", "10003", "10108"], + "disabled_active_scanners": [], "ignored_fingerprint_scanners": ["40018", "40024"], "max_spider_duration": 0, "max_scan_duration": 540, diff --git a/cmd/vulcan-zap/zap.go b/cmd/vulcan-zap/zap.go deleted file mode 100644 index 170d6a599..000000000 --- a/cmd/vulcan-zap/zap.go +++ /dev/null @@ -1,144 +0,0 @@ -/* -Copyright 2019 Adevinta -*/ - -package main - -import ( - "errors" - "fmt" - "math" - "strconv" - "strings" - - report "github.com/adevinta/vulcan-report" -) - -func riskToScore(risk string) float32 { - switch risk { - case "Informational": - return float32(report.SeverityThresholdNone) - case "Low": - return float32(report.SeverityThresholdLow) - case "Medium": - return float32(report.SeverityThresholdMedium) - case "High": - return float32(report.SeverityThresholdHigh) - } - - return float32(report.SeverityNone) -} - -func processAlert(a map[string]interface{}) (report.Vulnerability, error) { - var ok bool - var err error - - v := report.Vulnerability{} - - v.Summary, ok = a["name"].(string) - if !ok { - return report.Vulnerability{}, errors.New("Error retrieving vulnerability summary.") - } - - v.Description, ok = a["description"].(string) - if !ok { - return report.Vulnerability{}, fmt.Errorf("Error retrieving description for \"%v\".", v.Summary) - } - - v.Details, ok = a["other"].(string) - if !ok { - return report.Vulnerability{}, fmt.Errorf("Error retrieving details for \"%v\".", v.Summary) - } - - recommendations, ok := a["solution"].(string) - if !ok { - return report.Vulnerability{}, fmt.Errorf("Error retrieving recommendations for \"%v\".", v.Summary) - } - v.Recommendations = strings.Split(recommendations, "\n") - - references, ok := a["reference"].(string) - if !ok { - return report.Vulnerability{}, fmt.Errorf("Error retrieving references for \"%v\".", v.Summary) - } - v.References = strings.Split(references, "\n") - - risk, ok := a["risk"].(string) - if !ok { - return report.Vulnerability{}, fmt.Errorf("Error retrieving score for \"%v\".", v.Summary) - } - v.Score = riskToScore(risk) - - v.Labels = []string{"issue", "web", "zap"} - - cweID, ok := a["cweid"].(string) - if !ok { - return report.Vulnerability{}, fmt.Errorf("Error retrieving CWE ID for \"%v\".", v.Summary) - } - cweIDInt, err := strconv.Atoi(cweID) - if err != nil { - return report.Vulnerability{}, fmt.Errorf("Error converting CWE ID for \"%v\".", v.Summary) - } - // ZAP uses 2^32-1 as CWE ID for vulnerabilities without a known CWE. - if cweIDInt >= 0 && cweIDInt < math.MaxInt32-1 { - v.CWEID = uint32(cweIDInt) - } else { - v.CWEID = 0 - } - - resMethod, ok := a["method"].(string) - if !ok { - return report.Vulnerability{}, fmt.Errorf("Error retrieving method for \"%v\".", v.Summary) - } - - resURL, ok := a["url"].(string) - if !ok { - return report.Vulnerability{}, fmt.Errorf("Error retrieving URL for \"%v\".", v.Summary) - } - - resParam, ok := a["param"].(string) - if !ok { - return report.Vulnerability{}, fmt.Errorf("Error retrieving parameter for \"%v\".", v.Summary) - } - - resAttack, ok := a["attack"].(string) - if !ok { - return report.Vulnerability{}, fmt.Errorf("Error retrieving attack for \"%v\".", v.Summary) - } - - resEvidence, ok := a["evidence"].(string) - if !ok { - return report.Vulnerability{}, fmt.Errorf("Error retrieving evidence for \"%v\".", v.Summary) - } - - v.Resources = []report.ResourcesGroup{ - { - Name: "Affected Requests", - Header: []string{ - "Method", - "URL", - "Parameter", - "Attack", - "Evidence", - }, - Rows: []map[string]string{ - { - "Method": resMethod, - "URL": resURL, - "Parameter": resParam, - "Attack": resAttack, - "Evidence": resEvidence, - }, - }, - }, - } - - return v, nil -} - -func parsePluginID(a map[string]interface{}) (string, error) { - pluginID, ok := a["pluginId"].(string) - if !ok { - return "", errors.New("error parsing alert plugin ID") - } - return pluginID, nil -} diff --git a/go.mod b/go.mod index 8b36f385e..7e0e944bc 100644 --- a/go.mod +++ b/go.mod @@ -27,7 +27,6 @@ require ( github.com/satori/go.uuid v1.2.0 github.com/sirupsen/logrus v1.9.3 github.com/yhat/scrape v0.0.0-20161128144610-24b7890b0945 - github.com/zaproxy/zap-api-go v0.0.0-20231219145106-e9ebb9695484 golang.org/x/net v0.35.0 ) diff --git a/go.sum b/go.sum index f0a6ecabd..f874a18b8 100644 --- a/go.sum +++ b/go.sum @@ -170,8 +170,6 @@ github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= github.com/yhat/scrape v0.0.0-20161128144610-24b7890b0945 h1:6Ju8pZBYFTN9FaV/JvNBiIHcsgEmP4z4laciqjfjY8E= github.com/yhat/scrape v0.0.0-20161128144610-24b7890b0945/go.mod h1:4vRFPPNYllgCacoj+0FoKOjTW68rUhEfqPLiEJaK2w8= -github.com/zaproxy/zap-api-go v0.0.0-20231219145106-e9ebb9695484 h1:V///T0Z9mRKDi75TFSCFdaPr+WHI3lp9q3MKYhL9mmw= -github.com/zaproxy/zap-api-go v0.0.0-20231219145106-e9ebb9695484/go.mod h1:M+9bOOP3ffPDcPJ6N8oILPBSUEY/pbhk9emtkt2iHB8= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8= go.opentelemetry.io/otel v1.31.0 h1:NsJcKPIW0D0H3NgzPDHmo0WW6SptzPdqg/L1zsIm2hY= From f817dd6c214a9df73c114389917dba36e07b4e8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Carpintero?= Date: Fri, 14 Feb 2025 14:02:48 +0100 Subject: [PATCH 2/2] Include zap logs --- cmd/vulcan-zap/main.go | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/cmd/vulcan-zap/main.go b/cmd/vulcan-zap/main.go index cf5b1a051..8d16eaefc 100644 --- a/cmd/vulcan-zap/main.go +++ b/cmd/vulcan-zap/main.go @@ -268,11 +268,30 @@ func main() { // 2: At least one WARN and no FAILs // 3: Any other failure if exitCode != 0 { - logger.WithField("exitCode", exitCode).WithField("stdOut", string(out)).WithField("stdErr", string(outErr)).Info("Zap finished") + logger.WithField("exitCode", exitCode).Info("Zap finished") + logger := logger.WithField("dump", "zap.out") + for _, line := range strings.Split(string(out), "\n") { + logger.Info(line) + } + for _, line := range strings.Split(string(outErr), "\n") { + logger.Error(line) + } } res, err := os.ReadFile(fmt.Sprintf("%s/report.json", tempDir)) if err != nil { + if res, err := os.ReadFile(fmt.Sprintf("%s/zap.log", tempDir)); err != nil { + logger.Error("unable to read zap.log") + } else { + logger := logger.WithField("dump", "zap.log") + lines := strings.Split(string(res), "\n") + if len(lines) > 50 { + lines = lines[:50] + } + for _, line := range lines { + logger.Info(line) + } + } return fmt.Errorf("unable to read report.json: %v", err) }