diff --git a/pkg/attestation/crafter/materials/checkmarx.go b/pkg/attestation/crafter/materials/checkmarx.go index cb6b1b31d..fef3a5caa 100644 --- a/pkg/attestation/crafter/materials/checkmarx.go +++ b/pkg/attestation/crafter/materials/checkmarx.go @@ -19,7 +19,10 @@ import ( "context" "encoding/json" "fmt" + "maps" "os" + "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" @@ -59,6 +62,20 @@ type checkmarxResult struct { Data json.RawMessage `json:"data"` } +// checkmarxEngineToScanType maps Checkmarx's raw engine identifiers onto the +// canonical scan-type vocabulary. Checkmarx names some engines by product +// (e.g. "kics") rather than by category, so we normalize them here to keep the +// scan.types annotation consistent with other material kinds. An engine absent +// from this map is dropped from the annotation (fail closed): recognition never +// fires on a type we cannot classify, and no vendor-specific name leaks out. +var checkmarxEngineToScanType = map[string]string{ + "sast": ScanTypeSAST, + "sca": ScanTypeSCA, + "kics": ScanTypeIaC, + "containers": ScanTypeContainer, + "sscs": ScanTypeSupplyChain, +} + func NewCheckmarxCrafter(schema *schemaapi.CraftingSchema_Material, backend *casclient.CASBackend, l *zerolog.Logger) (*CheckmarxCrafter, error) { if schema.Type != schemaapi.CraftingSchema_Material_CHECKMARX_JSON { return nil, fmt.Errorf("material type is not a Checkmarx native JSON report") @@ -99,11 +116,14 @@ func (i *CheckmarxCrafter) Craft(ctx context.Context, filePath string) (*api.Att // Each real result carries a type and a data payload, regardless of engine // (sast, sca, kics, containers, sscs). similarityId carries omitempty in the // ast-cli structs and is left as a supporting signal only, to avoid rejecting - // valid reports. + // valid reports. We also collect the distinct engine types so attestation-level + // policies can tell which engines actually produced findings. + typeSet := map[string]struct{}{} for idx, r := range results { if r.Type == "" || r.Data == nil { return nil, fmt.Errorf("checkmarx result %d is missing type or data: %w", idx, ErrInvalidMaterialType) } + typeSet[strings.ToLower(r.Type)] = struct{}{} } if len(results) == 0 { @@ -116,14 +136,36 @@ func (i *CheckmarxCrafter) Craft(ctx context.Context, filePath string) (*api.Att return nil, err } - i.injectAnnotations(m) + i.injectAnnotations(m, typeSet) return m, nil } -func (i *CheckmarxCrafter) injectAnnotations(m *api.Attestation_Material) { +func (i *CheckmarxCrafter) injectAnnotations(m *api.Attestation_Material, typeSet map[string]struct{}) { if m.Annotations == nil { m.Annotations = make(map[string]string) } m.Annotations[AnnotationToolNameKey] = "checkmarx" + + // Normalize the raw Checkmarx engine identifiers onto the canonical scan-type + // vocabulary, dropping any engine we cannot classify so no vendor-specific + // name leaks into the annotation. + scanTypes := map[string]struct{}{} + for raw := range typeSet { + scanType, ok := checkmarxEngineToScanType[raw] + if !ok { + i.logger.Debug().Str("engine", raw).Msg("unrecognized Checkmarx engine type, omitting from scan.types annotation") + continue + } + scanTypes[scanType] = struct{}{} + } + + // Advertise the distinct scan types found in the report (sorted, comma-joined; + // e.g. "iac,sast,sca"). A clean/null report (or one with only unrecognized + // engines) yields no types, so the annotation is omitted rather than set + // empty: recognition then fails closed, the safe choice for a compliance gate. + if len(scanTypes) > 0 { + types := slices.Sorted(maps.Keys(scanTypes)) + m.Annotations[AnnotationScanTypesKey] = strings.Join(types, ",") + } } diff --git a/pkg/attestation/crafter/materials/checkmarx_test.go b/pkg/attestation/crafter/materials/checkmarx_test.go index f5ed28a10..b80fdd1d8 100644 --- a/pkg/attestation/crafter/materials/checkmarx_test.go +++ b/pkg/attestation/crafter/materials/checkmarx_test.go @@ -70,6 +70,10 @@ func TestCheckmarxCrafter_Craft(t *testing.T) { filePath string wantErr string annotations map[string]string + // absentAnnotations lists annotation keys that must NOT be set. A clean or + // null report advertises no engine types, so scan.types must be omitted + // (fail closed) rather than set to an empty value. + absentAnnotations []string }{ { name: "invalid path", @@ -92,6 +96,7 @@ func TestCheckmarxCrafter_Craft(t *testing.T) { annotations: map[string]string{ "chainloop.material.tool.name": "checkmarx", }, + absentAnnotations: []string{"chainloop.material.scan.types"}, }, { // The ast-cli serializer emits "results": null (not []) when a @@ -102,6 +107,7 @@ func TestCheckmarxCrafter_Craft(t *testing.T) { annotations: map[string]string{ "chainloop.material.tool.name": "checkmarx", }, + absentAnnotations: []string{"chainloop.material.scan.types"}, }, { // Real ast-cli output bundling multiple engines in one file (2 sast, @@ -111,6 +117,20 @@ func TestCheckmarxCrafter_Craft(t *testing.T) { filePath: "./testdata/checkmarx.json", annotations: map[string]string{ "chainloop.material.tool.name": "checkmarx", + // Engine types normalized to the canonical scan-type vocabulary + // (kics -> iac), lower-cased, sorted and comma-joined. + "chainloop.material.scan.types": "iac,sast,sca", + }, + }, + { + // containers -> container and sscs -> supply-chain are mapped to the + // canonical vocabulary; an unmapped engine ("future-engine") is dropped + // so no vendor-specific value leaks into the annotation. + name: "extra engines (containers + sscs + unmapped)", + filePath: "./testdata/checkmarx-extra-engines.json", + annotations: map[string]string{ + "chainloop.material.tool.name": "checkmarx", + "chainloop.material.scan.types": "container,supply-chain", }, }, } @@ -149,6 +169,11 @@ func TestCheckmarxCrafter_Craft(t *testing.T) { assert.Equal(t, v, got.Annotations[k]) } } + + for _, k := range tc.absentAnnotations { + _, ok := got.Annotations[k] + assert.False(t, ok, "annotation %q must not be set", k) + } }) } } diff --git a/pkg/attestation/crafter/materials/materials.go b/pkg/attestation/crafter/materials/materials.go index 50b925a8d..bf9b94404 100644 --- a/pkg/attestation/crafter/materials/materials.go +++ b/pkg/attestation/crafter/materials/materials.go @@ -53,6 +53,27 @@ const ( // (e.g. "ignored_paths"). The material name cannot carry it because input // names may contain underscores, which material names disallow. AnnotationPolicyInput = "chainloop.material.policy_input" + // AnnotationScanTypesKey records, on a multi-engine scan material, the + // distinct scan types present in the report (sorted, comma-joined; e.g. + // "iac,sast,sca"). It lets attestation-level policies that match purely by + // annotation (e.g. the *-scan-present compliance policies) tell which kinds of + // analysis actually ran without reading material content. Values are drawn + // from the canonical ScanType* vocabulary below, not from any single vendor's + // engine names, so the annotation stays consistent across material kinds. + AnnotationScanTypesKey = "chainloop.material.scan.types" +) + +// Canonical scan types for the AnnotationScanTypesKey annotation. A material +// crafter that inspects a multi-engine report must normalize each vendor's +// engine identifiers onto this generic vocabulary rather than emitting the raw +// names, keeping the annotation meaningful across material kinds. +const ( + ScanTypeSAST = "sast" // Static Application Security Testing + ScanTypeSCA = "sca" // Software Composition Analysis + ScanTypeIaC = "iac" // Infrastructure as Code scanning + ScanTypeContainer = "container" // Container image scanning + ScanTypeSupplyChain = "supply-chain" // Supply-chain security scanning + ScanTypeSecrets = "secrets" // Secret detection ) // IsLegacyAnnotation returns true if the annotation key is a legacy annotation diff --git a/pkg/attestation/crafter/materials/testdata/checkmarx-extra-engines.json b/pkg/attestation/crafter/materials/testdata/checkmarx-extra-engines.json new file mode 100644 index 000000000..e931d3ed8 --- /dev/null +++ b/pkg/attestation/crafter/materials/testdata/checkmarx-extra-engines.json @@ -0,0 +1,21 @@ +{ + "scanID": "b7d3a1e0-1f2c-4a5b-8c9d-0e1f2a3b4c5d", + "totalCount": 3, + "results": [ + { + "type": "containers", + "similarityId": "1000000001", + "data": {"packageName": "openssl", "packageVersion": "1.1.1"} + }, + { + "type": "sscs", + "similarityId": "1000000002", + "data": {"ruleName": "Missing branch protection"} + }, + { + "type": "future-engine", + "similarityId": "1000000003", + "data": {"foo": "bar"} + } + ] +}