From 11165136aae131e8bf8530b5f2b28e795437abb3 Mon Sep 17 00:00:00 2001 From: sg Date: Mon, 16 Sep 2024 17:25:02 +0100 Subject: [PATCH] introduce brakeman producer --- .../producers/brakeman/examples/README.md | 9 + .../brakeman/examples/guilleiguaran/dvra.json | 156 +++++++++++ components/producers/brakeman/main.go | 127 +++++++++ components/producers/brakeman/main_test.go | 256 ++++++++++++++++++ components/producers/brakeman/task.yaml | 49 ++++ 5 files changed, 597 insertions(+) create mode 100644 components/producers/brakeman/examples/README.md create mode 100644 components/producers/brakeman/examples/guilleiguaran/dvra.json create mode 100644 components/producers/brakeman/main.go create mode 100644 components/producers/brakeman/main_test.go create mode 100644 components/producers/brakeman/task.yaml diff --git a/components/producers/brakeman/examples/README.md b/components/producers/brakeman/examples/README.md new file mode 100644 index 000000000..62e32b427 --- /dev/null +++ b/components/producers/brakeman/examples/README.md @@ -0,0 +1,9 @@ +# Examples + +## `dvra.json` + +Using [the dvra repo](https://github.com/guilleiguaran/dvra). + +```bash +go run ../main.go -in ./examples/guilleiguaran/dvra.json -out ./out-dvra.pb +``` diff --git a/components/producers/brakeman/examples/guilleiguaran/dvra.json b/components/producers/brakeman/examples/guilleiguaran/dvra.json new file mode 100644 index 000000000..d1effc1da --- /dev/null +++ b/components/producers/brakeman/examples/guilleiguaran/dvra.json @@ -0,0 +1,156 @@ +{ + "scan_info": { + "app_path": "/code", + "rails_version": "4.x", + "security_warnings": 2, + "start_time": "2024-09-16 14:54:55 +0000", + "end_time": "2024-09-16 14:54:55 +0000", + "duration": 0.267453374, + "checks_performed": [ + "BasicAuth", + "BasicAuthTimingAttack", + "CSRFTokenForgeryCVE", + "ContentTag", + "CookieSerialization", + "CreateWith", + "CrossSiteScripting", + "DefaultRoutes", + "Deserialize", + "DetailedExceptions", + "DigestDoS", + "DivideByZero", + "DynamicFinders", + "EOLRails", + "EOLRuby", + "EscapeFunction", + "Evaluation", + "Execute", + "FileAccess", + "FileDisclosure", + "FilterSkipping", + "ForceSSL", + "ForgerySetting", + "HeaderDoS", + "I18nXSS", + "JRubyXML", + "JSONEncoding", + "JSONEntityEscape", + "JSONParsing", + "LinkTo", + "LinkToHref", + "MailTo", + "MassAssignment", + "MimeTypeDoS", + "ModelAttrAccessible", + "ModelAttributes", + "ModelSerialize", + "NestedAttributes", + "NestedAttributesBypass", + "NumberToCurrency", + "PageCachingCVE", + "Pathname", + "PermitAttributes", + "QuoteTableName", + "Ransack", + "Redirect", + "RegexDoS", + "Render", + "RenderDoS", + "RenderInline", + "ResponseSplitting", + "ReverseTabnabbing", + "RouteDoS", + "SQL", + "SQLCVEs", + "SSLVerify", + "SafeBufferManipulation", + "SanitizeConfigCve", + "SanitizeMethods", + "Secrets", + "SelectTag", + "SelectVulnerability", + "Send", + "SendFile", + "SessionManipulation", + "SessionSettings", + "SimpleFormat", + "SingleQuotes", + "SkipBeforeFilter", + "SprocketsPathTraversal", + "StripTags", + "SymbolDoS", + "SymbolDoSCVE", + "TemplateInjection", + "TranslateBug", + "UnsafeReflection", + "UnsafeReflectionMethods", + "UnscopedFind", + "ValidationRegex", + "VerbConfusion", + "WeakHash", + "WeakRSAKey", + "WithoutProtection", + "XMLDoS", + "YAMLParsing" + ], + "number_of_controllers": 4, + "number_of_models": 2, + "number_of_templates": 11, + "ruby_version": "3.3.4", + "brakeman_version": "6.2.1" + }, + "warnings": [ + { + "warning_type": "SQL Injection", + "warning_code": 0, + "fingerprint": "2377c284794a5ea965ede6303a2236ed4eb829642879477f30bdf94ad0f11054", + "check_name": "SQL", + "message": "Possible SQL injection", + "file": "dvra/app/controllers/sessions_controller.rb", + "line": 8, + "link": "https://brakemanscanner.org/docs/warning_types/sql_injection/", + "code": "User.find_by_sql(\"SELECT * FROM users WHERE email = '#{params[:email]}' AND password = '#{params[:password]}'\")", + "render_path": null, + "location": { + "type": "method", + "class": "SessionsController", + "method": "create" + }, + "user_input": "params[:email]", + "confidence": "High", + "cwe_id": [ + 89 + ] + }, + { + "warning_type": "Cross-Site Request Forgery", + "warning_code": 7, + "fingerprint": "8a1e3382d5e2bbbf94c19f5ad1cb9355df95f9231f9f426803ac8005b4a63c0b", + "check_name": "ForgerySetting", + "message": "`protect_from_forgery` should be called in `ApplicationController`", + "file": "dvra/app/controllers/application_controller.rb", + "line": 1, + "link": "https://brakemanscanner.org/docs/warning_types/cross-site_request_forgery/", + "code": null, + "render_path": null, + "location": { + "type": "controller", + "controller": "ApplicationController" + }, + "user_input": null, + "confidence": "High", + "cwe_id": [ + 352 + ] + } + ], + "ignored_warnings": [ + + ], + "errors": [ + + ], + "obsolete": [ + + ] +} diff --git a/components/producers/brakeman/main.go b/components/producers/brakeman/main.go new file mode 100644 index 000000000..8d4b66785 --- /dev/null +++ b/components/producers/brakeman/main.go @@ -0,0 +1,127 @@ +package main + +import ( + "encoding/json" + "fmt" + "log" + "log/slog" + + v1 "github.com/ocurity/dracon/api/proto/v1" + "github.com/ocurity/dracon/pkg/context" + + "github.com/ocurity/dracon/components/producers" +) + +func main() { + if err := producers.ParseFlags(); err != nil { + log.Fatal(err) + } + + inFile, err := producers.ReadInFile() + if err != nil { + log.Fatal(err) + } + + var results BrakemanOut + if err := json.Unmarshal(inFile, &results); err != nil { + log.Fatal(err) + } + + issues, err := parseIssues(&results) + if err != nil { + log.Fatal(err) + } + if err := producers.WriteDraconOut( + "brakeman", + issues, + ); err != nil { + log.Fatal(err) + } +} + +func handleLine(line string) (int, int) { + // can be both "line" or "line-line" + var start, end int + _, err := fmt.Sscanf(line, "%d-%d", &start, &end) + if err != nil { + _, err := fmt.Sscanf(line, "%d", &start) + if err != nil { + slog.Warn("Failed to parse line", "line", line) + } + end = start + } + return start, end +} + +func parseIssues(out *BrakemanOut) ([]*v1.Issue, error) { + issues := []*v1.Issue{} + for _, r := range out.Warnings { + start, end := handleLine(fmt.Sprintf("%d", r.Line)) + cwe := []int32{} + for _, c := range r.CweID { + cwe = append(cwe, int32(c)) + } + iss := &v1.Issue{ + Target: producers.GetFileTarget(r.File, start, end), + Type: fmt.Sprintf("%s:%d", r.WarningType, r.WarningCode), + Title: r.Message, + Severity: v1.Severity_SEVERITY_UNSPECIFIED, + Cvss: 0.0, + Cwe: cwe, + Confidence: v1.Confidence(v1.Confidence_value[fmt.Sprintf("CONFIDENCE_%s", r.Confidence)]), + Description: fmt.Sprintf("%s\n%s\n", r.Message, r.WarningType), + } + + // Extract the code snippet, if possible + code, err := context.ExtractCodeFromFileTarget(iss.Target) + if err != nil { + slog.Warn("Failed to extract code snippet", "error", err) + code = "" + } + iss.ContextSegment = &code + + issues = append(issues, iss) + } + return issues, nil +} + +type ScanInfo struct { + AppPath string `json:"app_path,omitempty"` + RailsVersion string `json:"rails_version,omitempty"` + SecurityWarnings int `json:"security_warnings,omitempty"` + StartTime string `json:"start_time,omitempty"` + EndTime string `json:"end_time,omitempty"` + Duration float64 `json:"duration,omitempty"` + ChecksPerformed []string `json:"checks_performed,omitempty"` + NumberOfControllers int `json:"number_of_controllers,omitempty"` + NumberOfModels int `json:"number_of_models,omitempty"` + NumberOfTemplates int `json:"number_of_templates,omitempty"` + RubyVersion string `json:"ruby_version,omitempty"` + BrakemanVersion string `json:"brakeman_version,omitempty"` +} +type BrakemanLocation struct { + Type string `json:"type,omitempty"` + Class string `json:"class,omitempty"` + Method string `json:"method,omitempty"` + Controller string `json:"controller,omitempty"` +} +type BrakemanWarning struct { + WarningType string `json:"warning_type,omitempty"` + WarningCode int `json:"warning_code,omitempty"` + Fingerprint string `json:"fingerprint,omitempty"` + CheckName string `json:"check_name,omitempty"` + Message string `json:"message,omitempty"` + File string `json:"file,omitempty"` + Line int `json:"line,omitempty"` + Link string `json:"link,omitempty"` + Code string `json:"code,omitempty"` + RenderPath any `json:"render_path,omitempty"` + Location BrakemanLocation `json:"location,omitempty"` + UserInput string `json:"user_input,omitempty"` + Confidence string `json:"confidence,omitempty"` + CweID []int `json:"cwe_id,omitempty"` +} +type BrakemanOut struct { + ScanInfo ScanInfo `json:"scan_info,omitempty"` + Warnings []BrakemanWarning `json:"warnings,omitempty"` +} diff --git a/components/producers/brakeman/main_test.go b/components/producers/brakeman/main_test.go new file mode 100644 index 000000000..dcfec197e --- /dev/null +++ b/components/producers/brakeman/main_test.go @@ -0,0 +1,256 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "testing" + + v1 "github.com/ocurity/dracon/api/proto/v1" + "github.com/ocurity/dracon/pkg/testutil" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseIssues(t *testing.T) { + f, err := testutil.CreateFile("brakeman_tests_vuln_code.rb", code) + require.NoError(t, err) + tempFileName := f.Name() + + defer func() { + require.NoError(t, os.Remove(tempFileName)) + }() + + exampleOutput := fmt.Sprintf(brakemanOut, tempFileName, tempFileName) + var results BrakemanOut + err = json.Unmarshal([]byte(exampleOutput), &results) + require.NoError(t, err) + + issues, err := parseIssues(&results) + require.NoError(t, err) + + expectedIssues := []*v1.Issue{&v1.Issue{ + Target: fmt.Sprintf("file://%s:1-1", tempFileName), + Type: "SQL Injection:0", + Title: "Possible SQL injection", + Severity: v1.Severity_SEVERITY_UNSPECIFIED, + Cvss: 0.0, + Cwe: []int32{89}, + Confidence: v1.Confidence_CONFIDENCE_HIGH, + Description: "Possible SQL injection\nSQL Injection\n", + ContextSegment: &code, + }, &v1.Issue{ + Target: fmt.Sprintf("file://%s:2-2", tempFileName), + Type: "Cross-Site Request Forgery:7", + Title: "protect_from_forgery should be called in ApplicationController", + Severity: v1.Severity_SEVERITY_UNSPECIFIED, + Cvss: 0.0, + Cwe: []int32{352}, + Confidence: v1.Confidence_CONFIDENCE_HIGH, + Description: "protect_from_forgery should be called in ApplicationController\nCross-Site Request Forgery\n", + ContextSegment: &code, + }, + } + + require.Equal(t, expectedIssues, issues) +} + +func TestHandleLine(t *testing.T) { + tc := []struct { + name string + line string + expectedStart int + expectedEnd int + }{ + { + name: "line-line", + line: "2-44", + expectedStart: 2, + expectedEnd: 44, + }, + { + name: "line", + line: "2", + expectedStart: 2, + expectedEnd: 2, + }, + { + name: "invalid", + line: "invalid", + expectedStart: 0, + expectedEnd: 0, + }, + } + + for _, tt := range tc { + t.Run(tt.name, func(t *testing.T) { + start, end := handleLine(tt.line) + assert.Equal(t, tt.expectedStart, start) + assert.Equal(t, tt.expectedEnd, end) + }) + } +} + +// Not valid ruby but we are only checking for parsing of brakeman results +var code = ` +User.find_by_sql(\"SELECT * FROM users WHERE email = '#{params[:email]}' AND password = '#{params[:password]}'\") +ApplicationController` +var brakemanOut = ` +{ + "scan_info": { + "app_path": "/code", + "rails_version": "4.x", + "security_warnings": 2, + "start_time": "2024-09-16 14:54:55 +0000", + "end_time": "2024-09-16 14:54:55 +0000", + "duration": 0.267453374, + "checks_performed": [ + "BasicAuth", + "BasicAuthTimingAttack", + "CSRFTokenForgeryCVE", + "ContentTag", + "CookieSerialization", + "CreateWith", + "CrossSiteScripting", + "DefaultRoutes", + "Deserialize", + "DetailedExceptions", + "DigestDoS", + "DivideByZero", + "DynamicFinders", + "EOLRails", + "EOLRuby", + "EscapeFunction", + "Evaluation", + "Execute", + "FileAccess", + "FileDisclosure", + "FilterSkipping", + "ForceSSL", + "ForgerySetting", + "HeaderDoS", + "I18nXSS", + "JRubyXML", + "JSONEncoding", + "JSONEntityEscape", + "JSONParsing", + "LinkTo", + "LinkToHref", + "MailTo", + "MassAssignment", + "MimeTypeDoS", + "ModelAttrAccessible", + "ModelAttributes", + "ModelSerialize", + "NestedAttributes", + "NestedAttributesBypass", + "NumberToCurrency", + "PageCachingCVE", + "Pathname", + "PermitAttributes", + "QuoteTableName", + "Ransack", + "Redirect", + "RegexDoS", + "Render", + "RenderDoS", + "RenderInline", + "ResponseSplitting", + "ReverseTabnabbing", + "RouteDoS", + "SQL", + "SQLCVEs", + "SSLVerify", + "SafeBufferManipulation", + "SanitizeConfigCve", + "SanitizeMethods", + "Secrets", + "SelectTag", + "SelectVulnerability", + "Send", + "SendFile", + "SessionManipulation", + "SessionSettings", + "SimpleFormat", + "SingleQuotes", + "SkipBeforeFilter", + "SprocketsPathTraversal", + "StripTags", + "SymbolDoS", + "SymbolDoSCVE", + "TemplateInjection", + "TranslateBug", + "UnsafeReflection", + "UnsafeReflectionMethods", + "UnscopedFind", + "ValidationRegex", + "VerbConfusion", + "WeakHash", + "WeakRSAKey", + "WithoutProtection", + "XMLDoS", + "YAMLParsing" + ], + "number_of_controllers": 4, + "number_of_models": 2, + "number_of_templates": 11, + "ruby_version": "3.3.4", + "brakeman_version": "6.2.1" + }, + "warnings": [ + { + "warning_type": "SQL Injection", + "warning_code": 0, + "fingerprint": "2377c284794a5ea965ede6303a2236ed4eb829642879477f30bdf94ad0f11054", + "check_name": "SQL", + "message": "Possible SQL injection", + "file": "%s", + "line": 1, + "link": "https://brakemanscanner.org/docs/warning_types/sql_injection/", + "code": "User.find_by_sql(\"SELECT * FROM users WHERE email = '#{params[:email]}' AND password = '#{params[:password]}'\")", + "render_path": null, + "location": { + "type": "method", + "class": "SessionsController", + "method": "create" + }, + "user_input": "params[:email]", + "confidence": "High", + "cwe_id": [ + 89 + ] + }, + { + "warning_type": "Cross-Site Request Forgery", + "warning_code": 7, + "fingerprint": "8a1e3382d5e2bbbf94c19f5ad1cb9355df95f9231f9f426803ac8005b4a63c0b", + "check_name": "ForgerySetting", + "message": "protect_from_forgery should be called in ApplicationController", + "file": "%s", + "line": 2, + "link": "https://brakemanscanner.org/docs/warning_types/cross-site_request_forgery/", + "code": null, + "render_path": null, + "location": { + "type": "controller", + "controller": "ApplicationController" + }, + "user_input": null, + "confidence": "High", + "cwe_id": [ + 352 + ] + } + ], + "ignored_warnings": [ + + ], + "errors": [ + + ], + "obsolete": [ + + ] +} +` diff --git a/components/producers/brakeman/task.yaml b/components/producers/brakeman/task.yaml new file mode 100644 index 000000000..027ea2b28 --- /dev/null +++ b/components/producers/brakeman/task.yaml @@ -0,0 +1,49 @@ +--- +apiVersion: tekton.dev/v1beta1 +kind: Task +metadata: + name: producer-brakeman + labels: + v1.dracon.ocurity.com/component: producer + v1.dracon.ocurity.com/test-type: sast + v1.dracon.ocurity.com/language: brakeman +spec: + description: Analyse Go source code to look for security issues. + params: + - name: producer-brakeman-flags + type: array + default: + - "--run-all-checks " + - "--skip-libs" + volumes: + - name: scratch + emptyDir: {} + workspaces: + - name: output + description: The workspace containing the source-code to scan. + steps: + - name: run-brakeman + image: presidentbeef/brakeman:v6.2.1.1 + command: [brakeman] + args: + - "$(params.producer-brakeman-flags[*])" + - "--format" + - "json" + - "--force-scan" + - "--output" + - "/scratch/out.json" + - "--path" + - "$(workspaces.output.path)/source-code/" + volumeMounts: + - mountPath: /scratch + name: scratch + - name: produce-issues + imagePullPolicy: IfNotPresent + image: '{{ default "ghcr.io/ocurity/dracon" .Values.image.registry }}/components/producers/golang-gosec:{{ .Chart.AppVersion }}' + command: ["/app/components/producers/golang-gosec/golang-gosec-parser"] + args: + - "-in=/scratch/out.json" + - "-out=$(workspaces.output.path)/.dracon/producers/golang-gosec.pb" + volumeMounts: + - mountPath: /scratch + name: scratch