package printer import ( "encoding/xml" "fmt" "os" "path/filepath" "sort" "strings" logger "github.com/kubescape/go-logger" "github.com/kubescape/go-logger/helpers" "github.com/kubescape/k8s-interface/workloadinterface" "github.com/kubescape/kubescape/v2/core/cautils" "github.com/kubescape/kubescape/v2/core/pkg/resultshandling/printer" "github.com/kubescape/opa-utils/reporthandling/results/v1/reportsummary" "github.com/kubescape/opa-utils/shared" ) const ( junitOutputFile = "report" junitOutputExt = ".xml" ) /* riskScore status */ type JunitPrinter struct { writer *os.File verbose bool } // https://llg.cubic.org/docs/junit/ type JUnitXML struct { TestSuites JUnitTestSuites `xml:"testsuites"` } // JUnitTestSuites represents the test summary type JUnitTestSuites struct { XMLName xml.Name `xml:"testsuites"` Suites []JUnitTestSuite `xml:"testsuite"` // list of controls Errors int `xml:"errors,attr"` // total number of tests with error result from all testsuites Failures int `xml:"failures,attr"` // total number of failed tests from all testsuites Tests int `xml:"tests,attr"` // total number of tests from all testsuites. Some software may expect to only see the number of successful tests from all testsuites though Time string `xml:"time,attr"` // time in seconds to execute all test suites Name string `xml:"name,attr"` // ? Add framework names ? } // JUnitTestSuite represents a single control type JUnitTestSuite struct { XMLName xml.Name `xml:"testsuite"` Tests int `xml:"tests,attr"` // total number of tests from this testsuite. Some software may expect to only see the number of successful tests though Name string `xml:"name,attr"` // Full (class) name of the test for non-aggregated testsuite documents. Class name without the package for aggregated testsuites documents. Required Errors int `xml:"errors,attr"` // The total number of tests in the suite that errors Failures int `xml:"failures,attr"` // The total number of tests in the suite that failed Hostname string `xml:"hostname,attr"` // Host on which the tests were executed ? cluster name ? ID int `xml:"id,attr"` // Starts at 0 for the first testsuite and is incremented by 1 for each following testsuite Skipped string `xml:"skipped,attr"` // The total number of skipped tests Time string `xml:"time,attr"` // Time taken (in seconds) to execute the tests in the suite Timestamp string `xml:"timestamp,attr"` // when the test was executed in ISO 8601 format (2014-01-21T16:17:18) Properties []JUnitProperty `xml:"properties>property,omitempty"` TestCases []JUnitTestCase `xml:"testcase"` } // JUnitTestCase represents a single resource type JUnitTestCase struct { XMLName xml.Name `xml:"testcase"` Classname string `xml:"classname,attr"` // Full class name for the class the test method is in. required Name string `xml:"name,attr"` // Name of the test method, required Time string `xml:"time,attr"` // Time taken (in seconds) to execute the test. optional SkipMessage *JUnitSkipMessage `xml:"skipped,omitempty"` Failure *JUnitFailure `xml:"failure,omitempty"` } // JUnitSkipMessage contains the reason why a testcase was skipped. type JUnitSkipMessage struct { Message string `xml:"message,attr"` } // JUnitProperty represents a key/value pair used to define properties. type JUnitProperty struct { Name string `xml:"name,attr"` Value string `xml:"value,attr"` } // JUnitFailure contains data related to a failed test. type JUnitFailure struct { Message string `xml:"message,attr"` Type string `xml:"type,attr"` Contents string `xml:",chardata"` } func NewJunitPrinter(verbose bool) *JunitPrinter { return &JunitPrinter{ verbose: verbose, } } func (jp *JunitPrinter) SetWriter(outputFile string) { if strings.TrimSpace(outputFile) == "" { outputFile = junitOutputFile } if filepath.Ext(strings.TrimSpace(outputFile)) != junitOutputExt { outputFile = outputFile + junitOutputExt } jp.writer = printer.GetWriter(outputFile) } func (jp *JunitPrinter) Score(score float32) { fmt.Fprintf(os.Stderr, "\nOverall risk-score (0- Excellent, 100- All failed): %d\n", cautils.Float32ToInt(score)) } func (jp *JunitPrinter) ActionPrint(opaSessionObj *cautils.OPASessionObj) { junitResult := testsSuites(opaSessionObj) postureReportStr, err := xml.Marshal(junitResult) if err != nil { logger.L().Fatal("failed to Marshal xml result object", helpers.Error(err)) } if _, err := jp.writer.Write(postureReportStr); err != nil { logger.L().Error("failed to write results", helpers.Error(err)) } else { printer.LogOutputFile(jp.writer.Name()) } } func testsSuites(results *cautils.OPASessionObj) *JUnitTestSuites { return &JUnitTestSuites{ Suites: listTestsSuite(results), Tests: results.Report.SummaryDetails.NumberOfControls().All(), Name: "Kubescape Scanning", Failures: results.Report.SummaryDetails.NumberOfControls().Failed(), } } func listTestsSuite(results *cautils.OPASessionObj) []JUnitTestSuite { var testSuites []JUnitTestSuite // control scan if len(results.Report.SummaryDetails.ListFrameworks()) == 0 { testSuite := JUnitTestSuite{} testSuite.Tests = results.Report.SummaryDetails.NumberOfControls().All() testSuite.Failures = results.Report.SummaryDetails.NumberOfControls().Failed() testSuite.Timestamp = results.Report.ReportGenerationTime.String() testSuite.ID = 0 testSuite.Name = "kubescape" testSuite.Properties = properties(results.Report.SummaryDetails.Score) testSuite.TestCases = testsCases(results, &results.Report.SummaryDetails.Controls, "Kubescape") testSuites = append(testSuites, testSuite) return testSuites } for i, f := range results.Report.SummaryDetails.Frameworks { testSuite := JUnitTestSuite{} testSuite.Tests = f.NumberOfControls().All() testSuite.Failures = f.NumberOfControls().Failed() testSuite.Timestamp = results.Report.ReportGenerationTime.String() testSuite.ID = i testSuite.Name = f.Name testSuite.Properties = properties(f.Score) testSuite.TestCases = testsCases(results, f.GetControls(), f.GetName()) testSuites = append(testSuites, testSuite) } return testSuites } func testsCases(results *cautils.OPASessionObj, controls reportsummary.IControlsSummaries, classname string) []JUnitTestCase { var testCases []JUnitTestCase iter := controls.ListControlsIDs().All() for iter.HasNext() { cID := iter.Next() testCase := JUnitTestCase{} control := results.Report.SummaryDetails.Controls.GetControl(reportsummary.EControlCriteriaID, cID) testCase.Name = control.GetName() testCase.Classname = classname if control.GetStatus().IsFailed() { resources := map[string]interface{}{} resourceIDs := control.ListResourcesIDs().Failed() for j := range resourceIDs { resource := results.AllResources[resourceIDs[j]] sourcePath := "" if ResourceSourcePath, ok := results.ResourceSource[resourceIDs[j]]; ok { sourcePath = ResourceSourcePath.RelativePath } resources[resourceToString(resource, sourcePath)] = nil } resourcesStr := shared.MapStringToSlice(resources) sort.Strings(resourcesStr) testCaseFailure := JUnitFailure{} testCaseFailure.Type = "Control" // testCaseFailure.Contents = testCaseFailure.Message = fmt.Sprintf("Remediation: %s\nMore details: %s\n\n%s", control.GetRemediation(), cautils.GetControlLink(control.GetID()), strings.Join(resourcesStr, "\n")) testCase.Failure = &testCaseFailure } else if control.GetStatus().IsSkipped() { testCase.SkipMessage = &JUnitSkipMessage{ Message: "", // TODO - fill after statusInfo is supported } } testCases = append(testCases, testCase) } return testCases } func resourceToString(resource workloadinterface.IMetadata, sourcePath string) string { sep := "; " s := "" s += fmt.Sprintf("apiVersion: %s", resource.GetApiVersion()) + sep s += fmt.Sprintf("kind: %s", resource.GetKind()) + sep if resource.GetNamespace() != "" { s += fmt.Sprintf("namespace: %s", resource.GetNamespace()) + sep } s += fmt.Sprintf("name: %s", resource.GetName()) if sourcePath != "" { s += sep + fmt.Sprintf("sourcePath: %s", sourcePath) } return s } func properties(riskScore float32) []JUnitProperty { return []JUnitProperty{ { Name: "riskScore", Value: fmt.Sprintf("%.2f", riskScore), }, } }