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
154 changes: 106 additions & 48 deletions app/controlplane/plugins/core/dependency-track/v1/extension.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"context"
"errors"
"fmt"
"text/template"

schemaapi "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1"
"github.com/chainloop-dev/chainloop/app/controlplane/plugins/core/dependency-track/v1/client"
Expand Down Expand Up @@ -63,7 +64,7 @@ func New(l log.Logger) (sdk.FanOut, error) {
base, err := sdk.NewFanOut(
&sdk.NewParams{
ID: "dependency-track",
Version: "1.2",
Version: "1.3",
Description: description,
Logger: l,
InputSchema: &sdk.InputSchema{
Expand Down Expand Up @@ -143,67 +144,117 @@ func (i *DependencyTrack) Attach(ctx context.Context, req *sdk.AttachmentRequest
return &sdk.AttachmentResponse{Configuration: rawConfig}, nil
}

// Send the SBOM to the configured Dependency Track instance
// Send the SBOMs to the configured Dependency Track instance
func (i *DependencyTrack) Execute(ctx context.Context, req *sdk.ExecutionRequest) error {
i.Logger.Info("execution requested")

var errs error
// Iterate over all SBOMs
for _, sbom := range req.Input.Materials {
// Make sure it's an SBOM and all the required configuration has been received
if err := validateExecuteOpts(sbom, req.RegistrationInfo, req.AttachmentInfo); err != nil {
return fmt.Errorf("running validation: %w", err)
if err := doExecute(ctx, req, sbom, i.Logger); err != nil {
errs = errors.Join(errs, err)
continue
}
}

// Extract registration configuration
var registrationConfig *registrationConfig
if err := sdk.FromConfig(req.RegistrationInfo.Configuration, &registrationConfig); err != nil {
return errors.New("invalid registration configuration")
}
if errs != nil {
return fmt.Errorf("executing: %w", errs)
}

// Extract attachment configuration
var attachmentConfig *attachmentConfig
if err := sdk.FromConfig(req.AttachmentInfo.Configuration, &attachmentConfig); err != nil {
return errors.New("invalid attachment configuration")
}
return nil
}

i.Logger.Infow("msg", "Uploading SBOM",
"materialName", sbom.Name,
"host", registrationConfig.Domain,
"projectID", attachmentConfig.ProjectID, "projectName", attachmentConfig.ProjectName,
"workflowID", req.Workflow.ID,
)

// Create an SBOM client and perform validation and upload
d, err := client.NewSBOMUploader(registrationConfig.Domain,
req.RegistrationInfo.Credentials.Password,
bytes.NewReader(sbom.Content),
attachmentConfig.ProjectID,
attachmentConfig.ProjectName)
if err != nil {
return fmt.Errorf("creating uploader: %w", err)
}
func doExecute(ctx context.Context, req *sdk.ExecutionRequest, sbom *sdk.ExecuteMaterial, l *log.Helper) error {
l.Info("execution requested")

if err := d.Validate(ctx); err != nil {
return fmt.Errorf("validating uploader: %w", err)
}
// Make sure it's an SBOM and all the required configuration has been received
if err := validateExecuteOpts(sbom, req.RegistrationInfo, req.AttachmentInfo); err != nil {
return fmt.Errorf("running validation: %w", err)
}

if err := d.Do(ctx); err != nil {
return fmt.Errorf("uploading SBOM: %w", err)
}
// Extract registration configuration
var registrationConfig *registrationConfig
if err := sdk.FromConfig(req.RegistrationInfo.Configuration, &registrationConfig); err != nil {
return errors.New("invalid registration configuration")
}

i.Logger.Infow("msg", "SBOM Uploaded",
"materialName", sbom.Name,
"host", registrationConfig.Domain,
"projectID", attachmentConfig.ProjectID, "projectName", attachmentConfig.ProjectName,
"workflowID", req.Workflow.ID,
)
// Extract attachment configuration
var attachmentConfig *attachmentConfig
if err := sdk.FromConfig(req.AttachmentInfo.Configuration, &attachmentConfig); err != nil {
return errors.New("invalid attachment configuration")
}

projectName, err := resolveProjectName(attachmentConfig.ProjectName, sbom.Annotations)
if err != nil {
// If we can't find the annotation for example, we skip the SBOM
l.Infow("msg", "failed to resolve project name, SKIPPING", "err", err, "materialName", sbom.Name)
return nil
}

i.Logger.Info("execution finished")
l.Infow("msg", "Uploading SBOM",
"materialName", sbom.Name,
"host", registrationConfig.Domain,
"projectID", attachmentConfig.ProjectID, "projectName", projectName,
"workflowID", req.Workflow.ID,
)

// Create an SBOM client and perform validation and upload
d, err := client.NewSBOMUploader(registrationConfig.Domain,
req.RegistrationInfo.Credentials.Password,
bytes.NewReader(sbom.Content),
attachmentConfig.ProjectID,
projectName)
if err != nil {
return fmt.Errorf("creating uploader: %w", err)
}

if err := d.Validate(ctx); err != nil {
return fmt.Errorf("validating uploader: %w", err)
}

if err := d.Do(ctx); err != nil {
return fmt.Errorf("uploading SBOM: %w", err)
}

l.Infow("msg", "SBOM Uploaded",
"materialName", sbom.Name,
"host", registrationConfig.Domain,
"projectID", attachmentConfig.ProjectID, "projectName", projectName,
"workflowID", req.Workflow.ID,
)

l.Info("execution finished")

return nil
}

type interpolationContext struct {
Material *interpolationContextMaterial
}
type interpolationContextMaterial struct {
Annotations map[string]string
}

// Resolve the project name template.
// We currently support the following template variables:
// - material.annotations.<key>
// For example, project-name => {{ material.annotations.my_annotation }}
func resolveProjectName(projectNameTpl string, annotations map[string]string) (string, error) {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is the new part.

data := interpolationContext{&interpolationContextMaterial{annotations}}

// The project name can contain template variables, useful to include annotations for example
// We do fail if the key can't be found
tpl, err := template.New("projectName").Option("missingkey=error").Parse(projectNameTpl)
if err != nil {
return "", fmt.Errorf("invalid project name: %w", err)
}

buf := bytes.NewBuffer(nil)
if err := tpl.Execute(buf, data); err != nil {
return "", fmt.Errorf("executing template: %w", err)
}

return buf.String(), nil
}

// i.e we want to attach to a dependency track integration and we are proving the right attachment options
// Not only syntactically but also semantically, i.e we can only request auto-creation of projects if the integration allows it
func validateAttachment(ctx context.Context, rc *registrationConfig, ac *attachmentRequest, credentials *sdk.Credentials) error {
Expand All @@ -229,8 +280,15 @@ func validateAttachmentConfiguration(rc *registrationConfig, ac *attachmentReque
return errors.New("invalid configuration")
}

if ac.ProjectName != "" && !rc.AllowAutoCreate {
return errors.New("auto creation of projects is not supported in this integration")
if ac.ProjectName != "" {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

and this validation at attachment time

if !rc.AllowAutoCreate {
return errors.New("auto creation of projects is not supported in this integration")
}

// The project name can contain template variables, useful to include annotations for example
if _, err := template.New("projectName").Parse(ac.ProjectName); err != nil {
return fmt.Errorf("invalid project name: %w", err)
}
}

if ac.ProjectID == "" && ac.ProjectName == "" {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,79 @@ func TestValidateRegistrationInput(t *testing.T) {
}
}

func TestResolveProjectName(t *testing.T) {
testCases := []struct {
name string
projectName string
wantErr bool
want string
}{
{
name: "no interpolation",
projectName: "hi",
want: "hi",
wantErr: false,
},
{
name: "no interpolation",
projectName: "{.Hello}",
want: "{.Hello}",
wantErr: false,
},
{
name: "nope",
projectName: "{.Hello",
want: "{.Hello",
wantErr: false,
},
{
name: "invalid template",
projectName: "{{.Hello",
wantErr: true,
},
{
name: "interpolated key",
projectName: "{{.Material.Annotations.Hello}}",
want: "hola",
},
{
name: "interpolated string",
projectName: "{{.Material.Annotations.Hello}}-project",
want: "hola-project",
},
{
name: "non-existing",
projectName: "{{.Material.Annotations.noVal}}",
want: "",
wantErr: true,
},
{
name: "non-existing-case",
projectName: "{{.Material.Annotations.hello}}",
wantErr: true,
},
}

data := map[string]string{
"Hello": "hola",
"World": "mundo",
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
var err error
got, err := resolveProjectName(tc.projectName, data)
if tc.wantErr {
assert.Error(t, err)
return
}

assert.NoError(t, err)
assert.Equal(t, tc.want, got)
})
}
}

func TestValidateAttachmentInput(t *testing.T) {
testCases := []struct {
name string
Expand Down
2 changes: 1 addition & 1 deletion docs/integrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ Below you can find the list of currently available integrations. If you can't fi

| ID | Version | Description | Material Requirement |
| --- | --- | --- | --- |
| [dependency-track](https://github.com/chainloop-dev/chainloop/blob/main/app/controlplane/plugins/core/dependency-track/v1/README.md) | 1.2 | Send CycloneDX SBOMs to your Dependency-Track instance | SBOM_CYCLONEDX_JSON |
| [dependency-track](https://github.com/chainloop-dev/chainloop/blob/main/app/controlplane/plugins/core/dependency-track/v1/README.md) | 1.3 | Send CycloneDX SBOMs to your Dependency-Track instance | SBOM_CYCLONEDX_JSON |
| [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 | |
| [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 |
| [oci-registry](https://github.com/chainloop-dev/chainloop/blob/main/app/controlplane/plugins/core/oci-registry/v1/README.md) | 1.0 | Send attestations to a compatible OCI registry | |
Expand Down