diff --git a/pkg/attestation/crafter/materials/sarif.go b/pkg/attestation/crafter/materials/sarif.go index a8ebf0d5f..5dce15ab6 100644 --- a/pkg/attestation/crafter/materials/sarif.go +++ b/pkg/attestation/crafter/materials/sarif.go @@ -18,6 +18,9 @@ package materials import ( "context" "fmt" + "maps" + "slices" + "strings" schemaapi "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1" api "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/api/attestation/v1" @@ -26,6 +29,12 @@ import ( "github.com/rs/zerolog" ) +// checkmarxVendorTag is the lower-cased vendor marker Checkmarx One stamps on +// every SARIF rule (properties.tags == ["security","checkmarx",""]) and +// embeds in its driver name ("Checkmarx One"). Either signal identifies a +// Checkmarx SARIF export. +const checkmarxVendorTag = "checkmarx" + type SARIFCrafter struct { backend *casclient.CASBackend *crafterCommon @@ -84,4 +93,102 @@ func (i *SARIFCrafter) injectAnnotations(m *api.Attestation_Material, doc *sarif if driver.Version != nil && *driver.Version != "" { m.Annotations[AnnotationToolVersionKey] = *driver.Version } + + // Checkmarx One exports every engine (sast, sca, kics, containers, sscs) under + // a single driver, so the driver name alone cannot tell attestation-level + // policies which analyses actually ran. Record the distinct engine types on the + // shared scan.types annotation, mirroring the native CHECKMARX_JSON crafter, so + // those policies (e.g. *-scan-present) match uniformly across material kinds. + if scanTypes := i.checkmarxScanTypes(doc); scanTypes != "" { + m.Annotations[AnnotationScanTypesKey] = scanTypes + } +} + +// checkmarxScanTypes inspects a SARIF report and returns the distinct scan types +// produced by its Checkmarx runs, normalized onto the canonical scan-type +// vocabulary and formatted for the AnnotationScanTypesKey annotation (sorted, +// comma-joined; e.g. "iac,sast,sca"). It returns "" when no Checkmarx run is +// present or none of its engines can be classified, so recognition fails closed +// and never over-claims for other tools. +// +// Detection and extraction are both per run: a SARIF document may bundle several +// runs (e.g. an aggregated report mixing tools), and only a Checkmarx run's +// "()" suffixes use the vocabulary we normalize. Gating extraction on the +// individual run keeps another tool's rule ids from being attributed to Checkmarx. +// +// The engine is read from each finding's ruleId, not the driver's rule catalog: +// Checkmarx's SARIF export carries no dedicated engine field (the EngineID +// property only exists in its sonar export), but ast-cli appends a "()" +// suffix to every result ruleId (e.g. "Reflected_XSS (sast)"), verified against +// ast-cli's findRuleID. Reading findings rather than tool.driver.rules (a catalog +// that need not correspond to findings) keeps the annotation findings-based, +// consistent with the native CHECKMARX_JSON crafter. +func (i *SARIFCrafter) checkmarxScanTypes(doc *sarif.Report) string { + scanTypes := map[string]struct{}{} + for _, run := range doc.Runs { + if !isCheckmarxRun(run) { + continue + } + for _, result := range run.Results { + if result == nil || result.RuleID == nil { + continue + } + engine := ruleIDEngineSuffix(*result.RuleID) + if engine == "" { + continue + } + scanType, ok := checkmarxEngineToScanType[strings.ToLower(engine)] + if !ok { + // Fail closed: an engine we cannot classify is dropped so no + // vendor-specific value leaks into the annotation. + i.logger.Debug().Str("engine", engine).Msg("unrecognized Checkmarx engine type, omitting from scan.types annotation") + continue + } + scanTypes[scanType] = struct{}{} + } + } + + if len(scanTypes) == 0 { + return "" + } + return strings.Join(slices.Sorted(maps.Keys(scanTypes)), ",") +} + +// isCheckmarxRun reports whether a single SARIF run looks like a Checkmarx One +// export. Checkmarx stamps its driver name ("Checkmarx One") and tags every rule +// with "checkmarx"; either signal is enough. +func isCheckmarxRun(run *sarif.Run) bool { + if run == nil || run.Tool == nil || run.Tool.Driver == nil { + return false + } + driver := run.Tool.Driver + if driver.Name != nil && strings.Contains(strings.ToLower(*driver.Name), checkmarxVendorTag) { + return true + } + for _, rule := range driver.Rules { + if rule == nil || rule.Properties == nil { + continue + } + for _, tag := range rule.Properties.Tags { + if strings.ToLower(tag) == checkmarxVendorTag { + return true + } + } + } + return false +} + +// ruleIDEngineSuffix extracts the engine identifier from the trailing +// "()" that ast-cli appends to every SARIF rule id (e.g. +// "Reflected_XSS (sast)" -> "sast"). It returns "" when no such suffix is present. +func ruleIDEngineSuffix(id string) string { + id = strings.TrimSpace(id) + if !strings.HasSuffix(id, ")") { + return "" + } + open := strings.LastIndex(id, "(") + if open < 0 { + return "" + } + return strings.TrimSpace(id[open+1 : len(id)-1]) } diff --git a/pkg/attestation/crafter/materials/sarif_test.go b/pkg/attestation/crafter/materials/sarif_test.go index b0c602a95..bf84131da 100644 --- a/pkg/attestation/crafter/materials/sarif_test.go +++ b/pkg/attestation/crafter/materials/sarif_test.go @@ -131,6 +131,103 @@ func TestSARIFCraft(t *testing.T) { } } +func TestSARIFCraft_ScanTypes(t *testing.T) { + testCases := []struct { + name string + filePath string + // annotations lists annotation keys that must be set to the given value. + annotations map[string]string + // absentAnnotations lists annotation keys that must NOT be set. A + // non-Checkmarx SARIF (or one with only unrecognized engines) advertises no + // engine types, so scan.types must be omitted (fail closed) rather than set + // to an empty value. + absentAnnotations []string + }{ + { + // Checkmarx One SARIF bundling multiple engines under a single driver. + // Engine types are read from rules[].properties.tags / the "(engine)" + // ruleId suffix and normalized to the canonical vocabulary (kics -> iac), + // sorted and comma-joined. + name: "checkmarx multi-engine SARIF", + filePath: "./testdata/checkmarx.sarif", + annotations: map[string]string{ + "chainloop.material.scan.types": "iac,sast,sca", + }, + }, + { + // containers -> container and sscs -> supply-chain map to the canonical + // vocabulary; an unmapped engine ("future-engine") is dropped so no + // vendor-specific value leaks into the annotation. + name: "checkmarx SARIF with extra + unmapped engines", + filePath: "./testdata/checkmarx-extra-engines.sarif", + annotations: map[string]string{ + "chainloop.material.scan.types": "container,supply-chain", + }, + }, + { + // Scan types are findings-based: the driver rule catalog lists sast, sca + // and kics rules, but only a sast finding is present, so scan.types must + // reflect just the engines that actually produced results (matching the + // native CHECKMARX_JSON crafter). + name: "checkmarx SARIF with rules but no findings for some engines", + filePath: "./testdata/checkmarx-rules-without-findings.sarif", + annotations: map[string]string{ + "chainloop.material.scan.types": "sast", + }, + }, + { + // A multi-run SARIF mixing a Checkmarx run (sast) with another tool's run + // whose rule ids reuse the "(engine)" suffix (sca) must only attribute the + // Checkmarx run's engines: detection and extraction are per run, so the + // other tool's findings never contaminate the annotation. + name: "multi-run SARIF only attributes checkmarx run engines", + filePath: "./testdata/checkmarx-multi-run.sarif", + annotations: map[string]string{ + "chainloop.material.scan.types": "sast", + }, + }, + { + // A non-Checkmarx SARIF (tfsec) must never get a scan.types annotation: + // the engine normalization is Checkmarx-specific, so recognition fails + // closed for other tools rather than over-claiming. + name: "non-checkmarx SARIF gets no scan.types", + filePath: "./testdata/report.sarif", + absentAnnotations: []string{"chainloop.material.scan.types"}, + }, + } + + l := zerolog.Nop() + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + uploader := mUploader.NewUploader(t) + uploader.On("Upload", context.TODO(), mock.Anything, mock.Anything, mock.Anything). + Return(&casclient.UpDownStatus{ + Digest: "deadbeef", + Filename: "report.sarif", + }, nil) + + schema := &contractAPI.CraftingSchema_Material{ + Name: "test", + Type: contractAPI.CraftingSchema_Material_SARIF, + } + backend := &casclient.CASBackend{Uploader: uploader} + crafter, err := materials.NewSARIFCrafter(schema, backend, &l) + require.NoError(t, err) + + got, err := crafter.Craft(context.TODO(), tc.filePath) + require.NoError(t, err) + + for k, v := range tc.annotations { + assert.Equal(t, v, got.Annotations[k], "annotation %q", k) + } + for _, k := range tc.absentAnnotations { + _, ok := got.Annotations[k] + assert.False(t, ok, "annotation %q must not be set", k) + } + }) + } +} + func TestSARIFCraft_SkipUpload(t *testing.T) { testCases := []struct { name string diff --git a/pkg/attestation/crafter/materials/testdata/checkmarx-extra-engines.sarif b/pkg/attestation/crafter/materials/testdata/checkmarx-extra-engines.sarif new file mode 100644 index 000000000..184ded9dc --- /dev/null +++ b/pkg/attestation/crafter/materials/testdata/checkmarx-extra-engines.sarif @@ -0,0 +1,97 @@ +{ + "$schema": "https://json.schemastore.org/sarif-2.1.0.json", + "version": "2.1.0", + "runs": [ + { + "tool": { + "driver": { + "name": "Checkmarx One", + "version": "1.0", + "informationUri": "https://checkmarx.com/resource/documents/en/34965-67042-checkmarx-one.html", + "rules": [ + { + "id": "openssl - CVE-2022-0001 (containers)", + "name": "openssl - CVE-2022-0001", + "helpUri": "https://checkmarx.com", + "help": { + "text": "", + "markdown": "" + }, + "fullDescription": { + "text": "Vulnerable package in container image" + }, + "properties": { + "security-severity": "9.0", + "name": "openssl - CVE-2022-0001", + "id": "openssl - CVE-2022-0001 (containers)", + "description": "Vulnerable package in container image", + "tags": ["security", "checkmarx", "containers"] + } + }, + { + "id": "Missing branch protection (sscs)", + "name": "Missing branch protection", + "helpUri": "https://checkmarx.com", + "help": { + "text": "", + "markdown": "" + }, + "fullDescription": { + "text": "Repository is missing branch protection" + }, + "properties": { + "security-severity": "6.0", + "name": "Missing branch protection", + "id": "Missing branch protection (sscs)", + "description": "Repository is missing branch protection", + "tags": ["security", "checkmarx", "sscs"] + } + }, + { + "id": "Some future finding (future-engine)", + "name": "Some future finding", + "helpUri": "https://checkmarx.com", + "help": { + "text": "", + "markdown": "" + }, + "fullDescription": { + "text": "Finding from an engine we do not classify yet" + }, + "properties": { + "security-severity": "5.0", + "name": "Some future finding", + "id": "Some future finding (future-engine)", + "description": "Finding from an engine we do not classify yet", + "tags": ["security", "checkmarx", "future-engine"] + } + } + ] + } + }, + "results": [ + { + "ruleId": "openssl - CVE-2022-0001 (containers)", + "level": "error", + "message": { + "text": "Vulnerable package openssl in image" + } + }, + { + "ruleId": "Missing branch protection (sscs)", + "level": "warning", + "message": { + "text": "Branch protection not enabled" + } + }, + { + "ruleId": "Some future finding (future-engine)", + "level": "note", + "message": { + "text": "Future engine finding" + } + } + ] + } + ] +} diff --git a/pkg/attestation/crafter/materials/testdata/checkmarx-multi-run.sarif b/pkg/attestation/crafter/materials/testdata/checkmarx-multi-run.sarif new file mode 100644 index 000000000..303aad8d8 --- /dev/null +++ b/pkg/attestation/crafter/materials/testdata/checkmarx-multi-run.sarif @@ -0,0 +1,59 @@ +{ + "$schema": "https://json.schemastore.org/sarif-2.1.0.json", + "version": "2.1.0", + "runs": [ + { + "tool": { + "driver": { + "name": "Checkmarx One", + "version": "1.0", + "informationUri": "https://checkmarx.com/resource/documents/en/34965-67042-checkmarx-one.html", + "rules": [ + { + "id": "Reflected_XSS (sast)", + "name": "ReflectedXss", + "helpUri": "https://checkmarx.com", + "help": { "text": "", "markdown": "" }, + "fullDescription": { "text": "Reflected XSS" }, + "properties": { + "security-severity": "8.0", + "name": "Reflected_XSS", + "id": "Reflected_XSS (sast)", + "description": "Reflected XSS", + "tags": ["security", "checkmarx", "sast"] + } + } + ] + } + }, + "results": [ + { + "ruleId": "Reflected_XSS (sast)", + "level": "error", + "message": { "text": "Reflected XSS in handler.go" } + } + ] + }, + { + "tool": { + "driver": { + "name": "some-other-tool", + "informationUri": "https://example.com", + "rules": [ + { + "id": "Vulnerable dependency (sca)", + "shortDescription": { "text": "Vulnerable dependency" } + } + ] + } + }, + "results": [ + { + "ruleId": "Vulnerable dependency (sca)", + "level": "error", + "message": { "text": "Vulnerable dependency from a non-Checkmarx tool" } + } + ] + } + ] +} diff --git a/pkg/attestation/crafter/materials/testdata/checkmarx-rules-without-findings.sarif b/pkg/attestation/crafter/materials/testdata/checkmarx-rules-without-findings.sarif new file mode 100644 index 000000000..d9d97d9b5 --- /dev/null +++ b/pkg/attestation/crafter/materials/testdata/checkmarx-rules-without-findings.sarif @@ -0,0 +1,66 @@ +{ + "$schema": "https://json.schemastore.org/sarif-2.1.0.json", + "version": "2.1.0", + "runs": [ + { + "tool": { + "driver": { + "name": "Checkmarx One", + "version": "1.0", + "informationUri": "https://checkmarx.com/resource/documents/en/34965-67042-checkmarx-one.html", + "rules": [ + { + "id": "Reflected_XSS (sast)", + "name": "ReflectedXss", + "helpUri": "https://checkmarx.com", + "help": { "text": "", "markdown": "" }, + "fullDescription": { "text": "Reflected XSS" }, + "properties": { + "security-severity": "8.0", + "name": "Reflected_XSS", + "id": "Reflected_XSS (sast)", + "description": "Reflected XSS", + "tags": ["security", "checkmarx", "sast"] + } + }, + { + "id": "CVE-2021-1234 (sca)", + "name": "Cve20211234", + "helpUri": "https://checkmarx.com", + "help": { "text": "", "markdown": "" }, + "fullDescription": { "text": "Vulnerable dependency" }, + "properties": { + "security-severity": "9.0", + "name": "CVE-2021-1234", + "id": "CVE-2021-1234 (sca)", + "description": "Vulnerable dependency", + "tags": ["security", "checkmarx", "sca"] + } + }, + { + "id": "Passwords And Secrets - Generic Password (kics)", + "name": "Passwords And Secrets - Generic Password", + "helpUri": "https://checkmarx.com", + "help": { "text": "", "markdown": "" }, + "fullDescription": { "text": "Generic password in IaC file" }, + "properties": { + "security-severity": "7.0", + "name": "Passwords And Secrets - Generic Password", + "id": "Passwords And Secrets - Generic Password (kics)", + "description": "Generic password in IaC file", + "tags": ["security", "checkmarx", "kics"] + } + } + ] + } + }, + "results": [ + { + "ruleId": "Reflected_XSS (sast)", + "level": "error", + "message": { "text": "Reflected XSS in handler.go" } + } + ] + } + ] +} diff --git a/pkg/attestation/crafter/materials/testdata/checkmarx.sarif b/pkg/attestation/crafter/materials/testdata/checkmarx.sarif new file mode 100644 index 000000000..740aabef3 --- /dev/null +++ b/pkg/attestation/crafter/materials/testdata/checkmarx.sarif @@ -0,0 +1,109 @@ +{ + "$schema": "https://json.schemastore.org/sarif-2.1.0.json", + "version": "2.1.0", + "runs": [ + { + "tool": { + "driver": { + "name": "Checkmarx One", + "version": "1.0", + "informationUri": "https://checkmarx.com/resource/documents/en/34965-67042-checkmarx-one.html", + "rules": [ + { + "id": "Reflected_XSS (sast)", + "name": "ReflectedXss", + "helpUri": "https://checkmarx.com", + "help": { + "text": "", + "markdown": "" + }, + "fullDescription": { + "text": "Reflected XSS" + }, + "properties": { + "security-severity": "8.0", + "name": "Reflected_XSS", + "id": "Reflected_XSS (sast)", + "description": "Reflected XSS", + "tags": ["security", "checkmarx", "sast"] + } + }, + { + "id": "CVE-2021-1234 (sca)", + "name": "Cve20211234", + "helpUri": "https://checkmarx.com", + "help": { + "text": "", + "markdown": "" + }, + "fullDescription": { + "text": "Vulnerable dependency" + }, + "properties": { + "security-severity": "9.0", + "name": "CVE-2021-1234", + "id": "CVE-2021-1234 (sca)", + "description": "Vulnerable dependency", + "tags": ["security", "checkmarx", "sca"] + } + }, + { + "id": "Passwords And Secrets - Generic Password (kics)", + "name": "Passwords And Secrets - Generic Password", + "helpUri": "https://checkmarx.com", + "help": { + "text": "", + "markdown": "" + }, + "fullDescription": { + "text": "Generic password in IaC file" + }, + "properties": { + "security-severity": "7.0", + "name": "Passwords And Secrets - Generic Password", + "id": "Passwords And Secrets - Generic Password (kics)", + "description": "Generic password in IaC file", + "tags": ["security", "checkmarx", "kics"] + } + } + ] + } + }, + "results": [ + { + "ruleId": "Reflected_XSS (sast)", + "level": "error", + "message": { + "text": "Reflected XSS in handler.go" + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "handler.go" + }, + "region": { + "startLine": 42 + } + } + } + ] + }, + { + "ruleId": "CVE-2021-1234 (sca)", + "level": "error", + "message": { + "text": "Vulnerable dependency detected" + } + }, + { + "ruleId": "Passwords And Secrets - Generic Password (kics)", + "level": "warning", + "message": { + "text": "Generic password found in main.tf" + } + } + ] + } + ] +}