From 9c3840b8bf6b4da14f7d1b8fd99ab52378b48bb7 Mon Sep 17 00:00:00 2001 From: Javier Rodriguez Date: Thu, 16 Jul 2026 07:49:32 +0200 Subject: [PATCH 1/2] feat(materials): record checkmarx engine types via scan.types annotation The Checkmarx crafter is the one place that sees report content, so have it record the distinct engine types present in a CHECKMARX_JSON report as a chainloop.material.scan.types annotation (lower-cased, sorted, comma-joined; e.g. "kics,sast,sca"). This lets attestation-level policies that match purely by annotation (the *-scan-present compliance policies) tell which engines actually produced findings without reading material content, avoiding over-claim on multi-engine reports. A clean or null report advertises no types, so the annotation is omitted rather than set empty and recognition fails closed. Assisted-by: Claude Code Signed-off-by: Javier Rodriguez Chainloop-Trace-Sessions: 23990771-b817-46fa-8543-353be0e9155a --- .../crafter/materials/checkmarx.go | 21 ++++++++++++++++--- .../crafter/materials/checkmarx_test.go | 13 ++++++++++++ .../crafter/materials/materials.go | 6 ++++++ 3 files changed, 37 insertions(+), 3 deletions(-) diff --git a/pkg/attestation/crafter/materials/checkmarx.go b/pkg/attestation/crafter/materials/checkmarx.go index cb6b1b31d..2d54153f1 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" @@ -99,11 +102,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 +122,23 @@ 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" + + // Advertise the distinct engine types found in the report (sorted, + // comma-joined; e.g. "kics,sast,sca"). A clean/null report yields no types, so + // the annotation is omitted rather than set empty: recognition then fails + // closed, which is the safe choice for a compliance gate. + if len(typeSet) > 0 { + types := slices.Sorted(maps.Keys(typeSet)) + 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..570b6f184 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,8 @@ func TestCheckmarxCrafter_Craft(t *testing.T) { filePath: "./testdata/checkmarx.json", annotations: map[string]string{ "chainloop.material.tool.name": "checkmarx", + // Distinct engine types, lower-cased and sorted, comma-joined. + "chainloop.material.scan.types": "kics,sast,sca", }, }, } @@ -149,6 +157,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..42a276c22 100644 --- a/pkg/attestation/crafter/materials/materials.go +++ b/pkg/attestation/crafter/materials/materials.go @@ -53,6 +53,12 @@ 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 scanner engine types present in the report (lower-cased, sorted, + // comma-joined; e.g. "kics,sast,sca"). It lets attestation-level policies + // that match purely by annotation (e.g. the *-scan-present compliance + // policies) tell which engines actually ran without reading material content. + AnnotationScanTypesKey = "chainloop.material.scan.types" ) // IsLegacyAnnotation returns true if the annotation key is a legacy annotation From 61eeb70ced1f47a9dc58932d55c0d29a45f3ba6d Mon Sep 17 00:00:00 2001 From: Javier Rodriguez Date: Thu, 16 Jul 2026 15:03:04 +0200 Subject: [PATCH 2/2] refactor(materials): normalize checkmarx scan types to a canonical vocabulary Checkmarx names some engines by product (e.g. "kics") rather than by category, so emitting the raw values mixed vendor-specific names with generic scan types in the chainloop.material.scan.types annotation. Introduce a canonical ScanType* vocabulary (sast, sca, iac, container, supply-chain, secrets) in the shared materials package and map the Checkmarx engine identifiers onto it (kics -> iac, containers -> container, sscs -> supply-chain). The annotation now carries only generic scan types, so it stays consistent across material kinds and can be adopted by other multi-engine materials. An engine that maps to no canonical type is dropped from the annotation and logged, so recognition fails closed and no vendor-specific name leaks out. Assisted-by: Claude Code Signed-off-by: Javier Rodriguez Chainloop-Trace-Sessions: 23990771-b817-46fa-8543-353be0e9155a --- .../crafter/materials/checkmarx.go | 39 ++++++++++++++++--- .../crafter/materials/checkmarx_test.go | 16 +++++++- .../crafter/materials/materials.go | 23 +++++++++-- .../testdata/checkmarx-extra-engines.json | 21 ++++++++++ 4 files changed, 87 insertions(+), 12 deletions(-) create mode 100644 pkg/attestation/crafter/materials/testdata/checkmarx-extra-engines.json diff --git a/pkg/attestation/crafter/materials/checkmarx.go b/pkg/attestation/crafter/materials/checkmarx.go index 2d54153f1..fef3a5caa 100644 --- a/pkg/attestation/crafter/materials/checkmarx.go +++ b/pkg/attestation/crafter/materials/checkmarx.go @@ -62,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") @@ -133,12 +147,25 @@ func (i *CheckmarxCrafter) injectAnnotations(m *api.Attestation_Material, typeSe } m.Annotations[AnnotationToolNameKey] = "checkmarx" - // Advertise the distinct engine types found in the report (sorted, - // comma-joined; e.g. "kics,sast,sca"). A clean/null report yields no types, so - // the annotation is omitted rather than set empty: recognition then fails - // closed, which is the safe choice for a compliance gate. - if len(typeSet) > 0 { - types := slices.Sorted(maps.Keys(typeSet)) + // 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 570b6f184..b80fdd1d8 100644 --- a/pkg/attestation/crafter/materials/checkmarx_test.go +++ b/pkg/attestation/crafter/materials/checkmarx_test.go @@ -117,8 +117,20 @@ func TestCheckmarxCrafter_Craft(t *testing.T) { filePath: "./testdata/checkmarx.json", annotations: map[string]string{ "chainloop.material.tool.name": "checkmarx", - // Distinct engine types, lower-cased and sorted, comma-joined. - "chainloop.material.scan.types": "kics,sast,sca", + // 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", }, }, } diff --git a/pkg/attestation/crafter/materials/materials.go b/pkg/attestation/crafter/materials/materials.go index 42a276c22..bf9b94404 100644 --- a/pkg/attestation/crafter/materials/materials.go +++ b/pkg/attestation/crafter/materials/materials.go @@ -54,13 +54,28 @@ const ( // names may contain underscores, which material names disallow. AnnotationPolicyInput = "chainloop.material.policy_input" // AnnotationScanTypesKey records, on a multi-engine scan material, the - // distinct scanner engine types present in the report (lower-cased, sorted, - // comma-joined; e.g. "kics,sast,sca"). It lets attestation-level policies - // that match purely by annotation (e.g. the *-scan-present compliance - // policies) tell which engines actually ran without reading material content. + // 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 func IsLegacyAnnotation(key string) bool { return key == AnnotationToolNameKey || key == AnnotationToolVersionKey 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"} + } + ] +}