Skip to content

Commit 8e2f1e1

Browse files
authored
feat(dependency-track): add fanout entry filtering (#2119)
Signed-off-by: Sylwester Piskozub <[email protected]>
1 parent 526833f commit 8e2f1e1

File tree

6 files changed

+170
-14
lines changed

6 files changed

+170
-14
lines changed

app/cli/cmd/attached_integration_add.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,11 @@ func newAttachedIntegrationAttachCmd() *cobra.Command {
2828
Use: "add",
2929
Aliases: []string{"attach"},
3030
Short: "Attach an existing registered integration to a workflow",
31-
Example: ` chainloop integration attached add --workflow deadbeef --project my-project --integration beefdoingwell --opt projectName=MyProject --opt projectVersion=1.0.0`,
31+
Example: ` chainloop integration attached add --workflow deadbeef --project my-project --integration beefdoingwell --opt projectName=MyProject --opt projectVersion=1.0.0
32+
33+
# Only send SBOMs to dependency track when the annotations in the attestation or material match the filter in an AND operation.
34+
Note: material annotations take precedence over attestation ones.
35+
chainloop integration attached add --workflow deadbeef --project my-project --integration dependency-track --opt projectName=MyProject --opt filter="environment=prod,team=security"`,
3236
RunE: func(_ *cobra.Command, _ []string) error {
3337
// Find the integration to extract the kind of integration we care about
3438
integration, err := action.NewRegisteredIntegrationDescribe(actionOpts).Run(integrationName)

app/cli/documentation/cli-reference.mdx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1363,6 +1363,10 @@ Examples
13631363

13641364
```
13651365
chainloop integration attached add --workflow deadbeef --project my-project --integration beefdoingwell --opt projectName=MyProject --opt projectVersion=1.0.0
1366+
1367+
Only send SBOMs to dependency track when the annotations in the attestation or material match the filter in an AND operation.
1368+
Note: material annotations take precedence over attestation ones.
1369+
chainloop integration attached add --workflow deadbeef --project my-project --integration dependency-track --opt projectName=MyProject --opt filter="environment=prod,team=security"
13661370
```
13671371

13681372
Options
@@ -2629,6 +2633,10 @@ Examples
26292633

26302634
```
26312635
chainloop integration attached add --workflow deadbeef --project my-project --integration beefdoingwell --opt projectName=MyProject --opt projectVersion=1.0.0
2636+
2637+
Only send SBOMs to dependency track when the annotations in the attestation or material match the filter in an AND operation.
2638+
Note: material annotations take precedence over attestation ones.
2639+
chainloop integration attached add --workflow deadbeef --project my-project --integration dependency-track --opt projectName=MyProject --opt filter="environment=prod,team=security"
26322640
```
26332641

26342642
Options

app/controlplane/plugins/core/dependency-track/v1/README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ See https://docs.chainloop.dev/guides/dependency-track/
4545

4646
|Field|Type|Required|Description|
4747
|---|---|---|---|
48+
|filter|string|no|Comma-separated annotation filters where material annotations take precedence over attestation when keys match|
4849
|parentID|string|no|ID of parent project to create a new project under|
4950
|projectID|string|no|The ID of the existing project to send the SBOMs to|
5051
|projectName|string|no|The name of the project to create and send the SBOMs to|
@@ -82,6 +83,11 @@ See https://docs.chainloop.dev/guides/dependency-track/
8283
"type": "string",
8384
"minLength": 1,
8485
"description": "ID of parent project to create a new project under"
86+
},
87+
"filter": {
88+
"type": "string",
89+
"minLength": 1,
90+
"description": "Comma-separated annotation filters where material annotations take precedence over attestation when keys match"
8591
}
8692
},
8793
"additionalProperties": false,

app/controlplane/plugins/core/dependency-track/v1/extension.go

Lines changed: 65 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ type attachmentRequest struct {
4949
ProjectName string `json:"projectName,omitempty" jsonschema:"oneof_required=projectName,minLength=1,description=The name of the project to create and send the SBOMs to"`
5050

5151
ParentID string `json:"parentID,omitempty" jsonschema:"minLength=1,description=ID of parent project to create a new project under"`
52+
Filter string `json:"filter,omitempty" jsonschema:"minLength=1,description=Comma-separated annotation filters where material annotations take precedence over attestation when keys match"`
5253
}
5354

5455
// Enforces the requirement that parentID requires the presence of projectName
@@ -71,6 +72,7 @@ type attachmentConfig struct {
7172
ProjectID string `json:"projectId"`
7273
ProjectName string `json:"projectName"`
7374
ParentID string `json:"parentId"`
75+
Filter string `json:"filter"`
7476
}
7577

7678
const description = "Send CycloneDX SBOMs to your Dependency-Track instance"
@@ -79,7 +81,7 @@ func New(l log.Logger) (sdk.FanOut, error) {
7981
base, err := sdk.NewFanOut(
8082
&sdk.NewParams{
8183
ID: "dependency-track",
82-
Version: "1.6",
84+
Version: "1.7",
8385
Description: description,
8486
Logger: l,
8587
InputSchema: &sdk.InputSchema{
@@ -148,10 +150,10 @@ func (i *DependencyTrack) Attach(ctx context.Context, req *sdk.AttachmentRequest
148150
return nil, fmt.Errorf("invalid attachment configuration: %w", err)
149151
}
150152

151-
i.Logger.Infow("msg", "attachment OK", "projectID", request.ProjectID, "projectName", request.ProjectName, "parentID", request.ParentID)
153+
i.Logger.Infow("msg", "attachment OK", "projectID", request.ProjectID, "projectName", request.ProjectName, "parentID", request.ParentID, "filter", request.Filter)
152154

153155
// We want to store the project configuration
154-
rawConfig, err := sdk.ToConfig(&attachmentConfig{ProjectID: request.ProjectID, ProjectName: request.ProjectName, ParentID: request.ParentID})
156+
rawConfig, err := sdk.ToConfig(&attachmentConfig{ProjectID: request.ProjectID, ProjectName: request.ProjectName, ParentID: request.ParentID, Filter: request.Filter})
155157
if err != nil {
156158
return nil, fmt.Errorf("marshalling configuration: %w", err)
157159
}
@@ -197,6 +199,16 @@ func doExecute(ctx context.Context, req *sdk.ExecutionRequest, sbom *sdk.Execute
197199
return errors.New("invalid attachment configuration")
198200
}
199201

202+
// Check if upload filter is specified and if it matches annotations
203+
if attachmentConfig.Filter != "" {
204+
attestationAnnotations := req.Input.Attestation.Predicate.GetAnnotations()
205+
materialAnnotations := sbom.Annotations
206+
if err := verifyAllFilters(attestationAnnotations, materialAnnotations, attachmentConfig.Filter); err != nil {
207+
l.Infow("msg", "filter conditions not met, SKIPPING", "err", err, "materialName", sbom.Name)
208+
return nil
209+
}
210+
}
211+
200212
// Calculate the project name based on the template
201213

202214
projectName, err := resolveProjectName(attachmentConfig.ProjectName, req.Input.Attestation.Predicate.GetAnnotations(), sbom.Annotations)
@@ -244,6 +256,38 @@ func doExecute(ctx context.Context, req *sdk.ExecutionRequest, sbom *sdk.Execute
244256
return nil
245257
}
246258

259+
// verify if all filter conditions are met by either:
260+
// 1) Material annotations (higher precedence if same key exists in both)
261+
// 2) Attestation annotations (fallback if key not in material)
262+
func verifyAllFilters(attestationAnnotations, materialAnnotations map[string]string, filter string) error {
263+
if filter == "" {
264+
return nil
265+
}
266+
267+
for _, f := range strings.Split(filter, ",") {
268+
parts := strings.SplitN(strings.TrimSpace(f), "=", 2)
269+
if len(parts) != 2 {
270+
return fmt.Errorf("invalid filter segment '%s' - must be 'key=value'", f)
271+
}
272+
273+
key, value := parts[0], parts[1]
274+
275+
if matVal, exists := materialAnnotations[key]; exists {
276+
if matVal != value {
277+
return fmt.Errorf("material annotation mismatch: %s=%s (expected %s)", key, matVal, value)
278+
}
279+
continue
280+
}
281+
282+
if attVal, exists := attestationAnnotations[key]; !exists {
283+
return fmt.Errorf("missing required annotation: %s", key)
284+
} else if attVal != value {
285+
return fmt.Errorf("attestation annotation mismatch: %s=%s (expected %s)", key, attVal, value)
286+
}
287+
}
288+
return nil
289+
}
290+
247291
type interpolationContext struct {
248292
Material *annotations
249293
Attestation *annotations
@@ -332,6 +376,10 @@ func validateAttachmentConfiguration(rc *registrationConfig, ac *attachmentReque
332376
return errors.New("project name must be provided to work with parent id")
333377
}
334378

379+
if err := validateFilter(ac.Filter); err != nil {
380+
return fmt.Errorf("invalid filter: %w", err)
381+
}
382+
335383
return nil
336384
}
337385

@@ -358,3 +406,17 @@ func validateExecuteOpts(m *sdk.ExecuteMaterial, regConfig *sdk.RegistrationResp
358406

359407
return nil
360408
}
409+
410+
func validateFilter(filter string) error {
411+
if filter == "" {
412+
return nil
413+
}
414+
415+
for _, f := range strings.Split(filter, ",") {
416+
f = strings.TrimSpace(f)
417+
if !strings.Contains(f, "=") || strings.HasPrefix(f, "=") || strings.HasSuffix(f, "=") {
418+
return fmt.Errorf("invalid filter segment '%s' - must be 'key=value'", f)
419+
}
420+
}
421+
return nil
422+
}

app/controlplane/plugins/core/dependency-track/v1/extension_test.go

Lines changed: 85 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -305,16 +305,18 @@ func TestValidateExecuteOpts(t *testing.T) {
305305

306306
func TestValidateAttachmentConfiguration(t *testing.T) {
307307
testCases := []struct {
308-
allowAutoCreate bool
309-
projectID, projectName, parentID string
310-
errMsg string
308+
allowAutoCreate bool
309+
projectID, projectName, parentID, filter string
310+
errMsg string
311311
}{
312-
{false, "project-id", "", "", ""},
313-
{true, "", "project-name", "", ""},
314-
{true, "", "project-name", "parent-id", ""},
315-
{false, "", "project-name", "", "auto creation of projects is not supported in this integration"},
316-
{false, "", "", "", "project id or name must be provided"},
317-
{false, "project-id", "", "parent-id", "project name must be provided to work with parent id"},
312+
{false, "project-id", "", "", "", ""},
313+
{true, "", "project-name", "", "", ""},
314+
{true, "", "project-name", "parent-id", "", ""},
315+
{false, "", "project-name", "", "", "auto creation of projects is not supported in this integration"},
316+
{false, "", "", "", "", "project id or name must be provided"},
317+
{false, "project-id", "", "parent-id", "", "project name must be provided to work with parent id"},
318+
{true, "", "project-name", "", "environment=prod", ""},
319+
{true, "", "project-name", "", "filter", "invalid filter: invalid filter segment 'filter' - must be 'key=value'"},
318320
}
319321

320322
for _, tc := range testCases {
@@ -326,6 +328,7 @@ func TestValidateAttachmentConfiguration(t *testing.T) {
326328
ProjectID: tc.projectID,
327329
ProjectName: tc.projectName,
328330
ParentID: tc.parentID,
331+
Filter: tc.filter,
329332
}
330333
err := validateAttachmentConfiguration(rc, ac)
331334
if tc.errMsg != "" {
@@ -335,3 +338,76 @@ func TestValidateAttachmentConfiguration(t *testing.T) {
335338
}
336339
}
337340
}
341+
342+
func TestVerifyAllFilters(t *testing.T) {
343+
attestationAnnotations := map[string]string{
344+
"environment": "prod",
345+
"team": "security",
346+
}
347+
348+
materialAnnotations := map[string]string{
349+
"environment": "staging",
350+
"critical": "true",
351+
}
352+
353+
testCases := []struct {
354+
name string
355+
filter string
356+
errMsg string
357+
}{
358+
{
359+
name: "material annotation exact match",
360+
filter: "critical=true",
361+
},
362+
{
363+
name: "material precedence over attestation for same key",
364+
filter: "environment=staging",
365+
},
366+
{
367+
name: "material value mismatch fails",
368+
filter: "environment=prod",
369+
errMsg: "material annotation mismatch",
370+
},
371+
{
372+
name: "attestation annotation fallback match",
373+
filter: "team=security",
374+
},
375+
{
376+
name: "attestation value mismatch fails",
377+
filter: "team=infra",
378+
errMsg: "attestation annotation mismatch",
379+
},
380+
{
381+
name: "missing annotation fails",
382+
filter: "nonexistent=value",
383+
errMsg: "missing required annotation",
384+
},
385+
{
386+
name: "multiple conditions all match",
387+
filter: "environment=staging,team=security",
388+
},
389+
{
390+
name: "one mismatched condition fails entire filter",
391+
filter: "environment=staging,team=infra",
392+
errMsg: "attestation annotation mismatch",
393+
},
394+
{
395+
name: "invalid filter format",
396+
filter: "environment",
397+
errMsg: "invalid filter segment",
398+
},
399+
}
400+
401+
for _, tc := range testCases {
402+
t.Run(tc.name, func(t *testing.T) {
403+
err := verifyAllFilters(attestationAnnotations, materialAnnotations, tc.filter)
404+
405+
if tc.errMsg != "" {
406+
require.Error(t, err)
407+
assert.Contains(t, err.Error(), tc.errMsg)
408+
} else {
409+
assert.NoError(t, err)
410+
}
411+
})
412+
}
413+
}

devel/integrations.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ Below you can find the list of currently available integrations. If you can't fi
1010

1111
| ID | Version | Description | Material Requirement |
1212
| --- | --- | --- | --- |
13-
| [dependency-track](https://github.com/chainloop-dev/chainloop/blob/main/app/controlplane/plugins/core/dependency-track/v1/README.md) | 1.6 | Send CycloneDX SBOMs to your Dependency-Track instance | SBOM_CYCLONEDX_JSON |
13+
| [dependency-track](https://github.com/chainloop-dev/chainloop/blob/main/app/controlplane/plugins/core/dependency-track/v1/README.md) | 1.7 | Send CycloneDX SBOMs to your Dependency-Track instance | SBOM_CYCLONEDX_JSON |
1414
| [discord-webhook](https://github.com/chainloop-dev/chainloop/blob/main/app/controlplane/plugins/core/discord-webhook/v1/README.md) | 1.1 | Send attestations to Discord | |
1515
| [guac](https://github.com/chainloop-dev/chainloop/blob/main/app/controlplane/plugins/core/guac/v1/README.md) | 1.0 | Export Attestation and SBOMs metadata to a blob storage backend so guacsec/guac can consume it | SBOM_CYCLONEDX_JSON, SBOM_SPDX_JSON |
1616
| [slack-webhook](https://github.com/chainloop-dev/chainloop/blob/main/app/controlplane/plugins/core/slack-webhook/v1/README.md) | 1.0 | Send attestations to Slack | |

0 commit comments

Comments
 (0)