Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 107 additions & 0 deletions pkg/attestation/crafter/materials/sarif.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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","<engine>"]) 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
Expand Down Expand Up @@ -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
// "(<engine>)" 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 "(<engine>)"
// 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
// "(<engine>)" 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])
}
97 changes: 97 additions & 0 deletions pkg/attestation/crafter/materials/sarif_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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"
}
}
]
}
]
}
Original file line number Diff line number Diff line change
@@ -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" }
}
]
}
]
}
Loading
Loading