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
48 changes: 45 additions & 3 deletions pkg/attestation/crafter/materials/checkmarx.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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 {
Expand All @@ -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, ",")
}
}
25 changes: 25 additions & 0 deletions pkg/attestation/crafter/materials/checkmarx_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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
Expand All @@ -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,
Expand All @@ -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",
},
},
}
Expand Down Expand Up @@ -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)
}
})
}
}
21 changes: 21 additions & 0 deletions pkg/attestation/crafter/materials/materials.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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"}
}
]
}
Loading