Explorar o código

Add junit report format (#920)

* add junit report format

* add fingerprint to expected test result to fix tests

* fix expected junit test report
Malte Morgenstern %!s(int64=2) %!d(string=hai) anos
pai
achega
0dbdde837f

+ 3 - 0
.gitignore

@@ -14,6 +14,9 @@ build
 .gitleaks.toml
 cmd/generate/config/gitleaks.toml
 
+# test results
+testdata/expected/report/*.got.*
+
 # Test binary
 *.out
 

+ 1 - 1
README.md

@@ -157,7 +157,7 @@ Flags:
       --no-color                   turn off color for verbose output
       --no-banner                  suppress banner
       --redact                     redact secrets from logs and stdout
-  -f, --report-format string       output format (json, csv, sarif) (default "json")
+  -f, --report-format string       output format (json, csv, junit, sarif) (default "json")
   -r, --report-path string         report file
   -s, --source string              path to source (default ".")
   -v, --verbose                    show verbose output from scan

+ 1 - 1
cmd/root.go

@@ -41,7 +41,7 @@ func init() {
 	rootCmd.PersistentFlags().Int("exit-code", 1, "exit code when leaks have been encountered")
 	rootCmd.PersistentFlags().StringP("source", "s", ".", "path to source")
 	rootCmd.PersistentFlags().StringP("report-path", "r", "", "report file")
-	rootCmd.PersistentFlags().StringP("report-format", "f", "json", "output format (json, csv, sarif)")
+	rootCmd.PersistentFlags().StringP("report-format", "f", "json", "output format (json, csv, junit, sarif)")
 	rootCmd.PersistentFlags().StringP("baseline-path", "b", "", "path to baseline with issues that can be ignored")
 	rootCmd.PersistentFlags().StringP("log-level", "l", "info", "log level (trace, debug, info, warn, error, fatal)")
 	rootCmd.PersistentFlags().BoolP("verbose", "v", false, "show verbose output from scan")

+ 102 - 0
report/junit.go

@@ -0,0 +1,102 @@
+package report
+
+import (
+	"encoding/json"
+	"encoding/xml"
+	"fmt"
+	"io"
+	"strconv"
+)
+
+func writeJunit(findings []Finding, w io.WriteCloser) error {
+	testSuites := TestSuites{
+		TestSuites: getTestSuites(findings),
+	}
+
+	io.WriteString(w, xml.Header)
+	encoder := xml.NewEncoder(w)
+	encoder.Indent("", "\t")
+	return encoder.Encode(testSuites)
+}
+
+func getTestSuites(findings []Finding) []TestSuite {
+	return []TestSuite{
+		{
+			Failures:  strconv.Itoa(len(findings)),
+			Name:      "gitleaks",
+			Tests:     strconv.Itoa(len(findings)),
+			TestCases: getTestCases(findings),
+			Time:      "",
+		},
+	}
+}
+
+func getTestCases(findings []Finding) []TestCase {
+	testCases := []TestCase{}
+	for _, f := range findings {
+		testCase := TestCase{
+			Classname: f.Description,
+			Failure:   getFailure(f),
+			File:      f.File,
+			Name:      getMessage(f),
+			Time:      "",
+		}
+		testCases = append(testCases, testCase)
+	}
+	return testCases
+}
+
+func getFailure(f Finding) Failure {
+	return Failure{
+		Data:    getData(f),
+		Message: getMessage(f),
+		Type:    f.Description,
+	}
+}
+
+func getData(f Finding) string {
+	data, err := json.MarshalIndent(f, "", "\t")
+	if err != nil {
+		fmt.Println(err)
+		return ""
+	}
+	return string(data)
+}
+
+func getMessage(f Finding) string {
+	if f.Commit == "" {
+		return fmt.Sprintf("%s has detected a secret in file %s, line %s.", f.RuleID, f.File, strconv.Itoa(f.StartLine))
+	}
+
+	return fmt.Sprintf("%s has detected a secret in file %s, line %s, at commit %s.", f.RuleID, f.File, strconv.Itoa(f.StartLine), f.Commit)
+}
+
+type TestSuites struct {
+	XMLName    xml.Name `xml:"testsuites"`
+	TestSuites []TestSuite
+}
+
+type TestSuite struct {
+	XMLName   xml.Name   `xml:"testsuite"`
+	Failures  string     `xml:"failures,attr"`
+	Name      string     `xml:"name,attr"`
+	Tests     string     `xml:"tests,attr"`
+	TestCases []TestCase `xml:"testcase"`
+	Time      string     `xml:"time,attr"`
+}
+
+type TestCase struct {
+	XMLName   xml.Name `xml:"testcase"`
+	Classname string   `xml:"classname,attr"`
+	Failure   Failure  `xml:"failure"`
+	File      string   `xml:"file,attr"`
+	Name      string   `xml:"name,attr"`
+	Time      string   `xml:"time,attr"`
+}
+
+type Failure struct {
+	XMLName xml.Name `xml:"failure"`
+	Data    string   `xml:",chardata"`
+	Message string   `xml:"message,attr"`
+	Type    string   `xml:"type,attr"`
+}

+ 107 - 0
report/junit_test.go

@@ -0,0 +1,107 @@
+package report
+
+import (
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+)
+
+func TestWriteJunit(t *testing.T) {
+	tests := []struct {
+		findings       []Finding
+		testReportName string
+		expected       string
+		wantEmpty      bool
+	}{
+		{
+			testReportName: "simple",
+			expected:       filepath.Join(expectPath, "report", "junit_simple.xml"),
+			findings: []Finding{
+				{
+
+					Description: "Test Rule",
+					RuleID:      "test-rule",
+					Match:       "line containing secret",
+					Secret:      "a secret",
+					StartLine:   1,
+					EndLine:     2,
+					StartColumn: 1,
+					EndColumn:   2,
+					Message:     "opps",
+					File:        "auth.py",
+					Commit:      "0000000000000000",
+					Author:      "John Doe",
+					Email:       "johndoe@gmail.com",
+					Date:        "10-19-2003",
+					Tags:        []string{},
+				},
+				{
+
+					Description: "Test Rule",
+					RuleID:      "test-rule",
+					Match:       "line containing secret",
+					Secret:      "a secret",
+					StartLine:   2,
+					EndLine:     3,
+					StartColumn: 1,
+					EndColumn:   2,
+					Message:     "",
+					File:        "auth.py",
+					Commit:      "",
+					Author:      "",
+					Email:       "",
+					Date:        "",
+					Tags:        []string{},
+				},
+			},
+		},
+		{
+			testReportName: "empty",
+			expected:       filepath.Join(expectPath, "report", "junit_empty.xml"),
+			findings:       []Finding{},
+		},
+	}
+
+	for _, test := range tests {
+		// create tmp file using os.TempDir()
+		tmpfile, err := os.Create(filepath.Join(tmpPath, test.testReportName+".xml"))
+		if err != nil {
+			os.Remove(tmpfile.Name())
+			t.Error(err)
+		}
+		err = writeJunit(test.findings, tmpfile)
+		if err != nil {
+			os.Remove(tmpfile.Name())
+			t.Error(err)
+		}
+		got, err := os.ReadFile(tmpfile.Name())
+		if err != nil {
+			os.Remove(tmpfile.Name())
+			t.Error(err)
+		}
+		if test.wantEmpty {
+			if len(got) > 0 {
+				os.Remove(tmpfile.Name())
+				t.Errorf("Expected empty file, got %s", got)
+			}
+			os.Remove(tmpfile.Name())
+			continue
+		}
+		want, err := os.ReadFile(test.expected)
+		if err != nil {
+			os.Remove(tmpfile.Name())
+			t.Error(err)
+		}
+
+		if string(got) != string(want) {
+			err = os.WriteFile(strings.Replace(test.expected, ".xml", ".got.xml", 1), got, 0644)
+			if err != nil {
+				t.Error(err)
+			}
+			t.Errorf("got %s, want %s", string(got), string(want))
+		}
+
+		os.Remove(tmpfile.Name())
+	}
+}

+ 2 - 0
report/report.go

@@ -24,6 +24,8 @@ func Write(findings []Finding, cfg config.Config, ext string, reportPath string)
 		err = writeJson(findings, file)
 	case ".csv", "csv":
 		err = writeCsv(findings, file)
+	case ".xml", "junit":
+		err = writeJunit(findings, file)
 	case ".sarif", "sarif":
 		err = writeSarif(cfg, findings, file)
 	}

+ 16 - 0
report/report_test.go

@@ -68,6 +68,22 @@ func TestReport(t *testing.T) {
 				},
 			},
 		},
+		{
+			ext: ".xml",
+			findings: []Finding{
+				{
+					RuleID: "test-rule",
+				},
+			},
+		},
+		{
+			ext: "junit",
+			findings: []Finding{
+				{
+					RuleID: "test-rule",
+				},
+			},
+		},
 		// {
 		// 	ext: "SARIF",
 		// 	findings: []Finding{

+ 4 - 0
testdata/expected/report/junit_empty.xml

@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<testsuites>
+	<testsuite failures="0" name="gitleaks" tests="0" time=""></testsuite>
+</testsuites>

+ 11 - 0
testdata/expected/report/junit_simple.xml

@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<testsuites>
+	<testsuite failures="2" name="gitleaks" tests="2" time="">
+		<testcase classname="Test Rule" file="auth.py" name="test-rule has detected a secret in file auth.py, line 1, at commit 0000000000000000." time="">
+			<failure message="test-rule has detected a secret in file auth.py, line 1, at commit 0000000000000000." type="Test Rule">{&#xA;&#x9;&#34;Description&#34;: &#34;Test Rule&#34;,&#xA;&#x9;&#34;StartLine&#34;: 1,&#xA;&#x9;&#34;EndLine&#34;: 2,&#xA;&#x9;&#34;StartColumn&#34;: 1,&#xA;&#x9;&#34;EndColumn&#34;: 2,&#xA;&#x9;&#34;Match&#34;: &#34;line containing secret&#34;,&#xA;&#x9;&#34;Secret&#34;: &#34;a secret&#34;,&#xA;&#x9;&#34;File&#34;: &#34;auth.py&#34;,&#xA;&#x9;&#34;SymlinkFile&#34;: &#34;&#34;,&#xA;&#x9;&#34;Commit&#34;: &#34;0000000000000000&#34;,&#xA;&#x9;&#34;Entropy&#34;: 0,&#xA;&#x9;&#34;Author&#34;: &#34;John Doe&#34;,&#xA;&#x9;&#34;Email&#34;: &#34;johndoe@gmail.com&#34;,&#xA;&#x9;&#34;Date&#34;: &#34;10-19-2003&#34;,&#xA;&#x9;&#34;Message&#34;: &#34;opps&#34;,&#xA;&#x9;&#34;Tags&#34;: [],&#xA;&#x9;&#34;RuleID&#34;: &#34;test-rule&#34;,&#xA;&#x9;&#34;Fingerprint&#34;: &#34;&#34;&#xA;}</failure>
+		</testcase>
+		<testcase classname="Test Rule" file="auth.py" name="test-rule has detected a secret in file auth.py, line 2." time="">
+			<failure message="test-rule has detected a secret in file auth.py, line 2." type="Test Rule">{&#xA;&#x9;&#34;Description&#34;: &#34;Test Rule&#34;,&#xA;&#x9;&#34;StartLine&#34;: 2,&#xA;&#x9;&#34;EndLine&#34;: 3,&#xA;&#x9;&#34;StartColumn&#34;: 1,&#xA;&#x9;&#34;EndColumn&#34;: 2,&#xA;&#x9;&#34;Match&#34;: &#34;line containing secret&#34;,&#xA;&#x9;&#34;Secret&#34;: &#34;a secret&#34;,&#xA;&#x9;&#34;File&#34;: &#34;auth.py&#34;,&#xA;&#x9;&#34;SymlinkFile&#34;: &#34;&#34;,&#xA;&#x9;&#34;Commit&#34;: &#34;&#34;,&#xA;&#x9;&#34;Entropy&#34;: 0,&#xA;&#x9;&#34;Author&#34;: &#34;&#34;,&#xA;&#x9;&#34;Email&#34;: &#34;&#34;,&#xA;&#x9;&#34;Date&#34;: &#34;&#34;,&#xA;&#x9;&#34;Message&#34;: &#34;&#34;,&#xA;&#x9;&#34;Tags&#34;: [],&#xA;&#x9;&#34;RuleID&#34;: &#34;test-rule&#34;,&#xA;&#x9;&#34;Fingerprint&#34;: &#34;&#34;&#xA;}</failure>
+		</testcase>
+	</testsuite>
+</testsuites>