Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
953e326
chore(feature/go): Trivy Integration
juliojimenez Oct 29, 2025
5e441d0
fix(debug): extract from wrapper function
juliojimenez Nov 7, 2025
3b433f5
fix(debug): extract json from zip
juliojimenez Nov 7, 2025
e36ba6e
fix(debug): remove debug print of sbom
juliojimenez Nov 7, 2025
96277d6
fix(aws): Some inputs are not longer required
juliojimenez Nov 7, 2025
4f25ce4
fix(aws): Some inputs are not longer required
juliojimenez Nov 7, 2025
7fec643
fix: add trivy to config validation
juliojimenez Nov 13, 2025
faf30d7
fix: add trivy to config validation
juliojimenez Nov 13, 2025
d8526dd
fix: add trivy to config validation
juliojimenez Nov 13, 2025
100d2b5
fix: ecr auth
juliojimenez Nov 14, 2025
667ce81
fix: trivy clickhouse table name
juliojimenez Nov 14, 2025
e7ee327
feat: ability to do application scope reports
juliojimenez Nov 15, 2025
22a8ea1
fix: i don't think org uuid is always required
juliojimenez Nov 15, 2025
745a1df
fix: if no projectUuids are provided
juliojimenez Nov 15, 2025
f4836d1
fix: mend-project-uuids
juliojimenez Nov 15, 2025
400b32d
fix: maxDepthLevel
juliojimenez Nov 15, 2025
fe1fe95
fix: maxDepthLevel
juliojimenez Nov 15, 2025
dd7ea7d
fix: maxDepthLevel
juliojimenez Nov 15, 2025
7d693a4
fix: maxDepthLevel
juliojimenez Nov 15, 2025
9f47927
fix: maxDepthLevel
juliojimenez Nov 15, 2025
5cbe317
fix: maxDepthLevel
juliojimenez Nov 15, 2025
c9adcf8
fix: maxDepthLevel
juliojimenez Nov 15, 2025
80055ec
fix: stuff
juliojimenez Nov 15, 2025
88b80be
feat: add merge
juliojimenez Nov 15, 2025
c995da9
feat: add merge
juliojimenez Nov 15, 2025
e7a5e5e
feat: add merge
juliojimenez Nov 15, 2025
4547913
feat: add merge
juliojimenez Nov 15, 2025
7b39d42
fix: lint
juliojimenez Nov 15, 2025
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
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ repos:

- id: go-cyclo
name: Check cyclomatic complexity
entry: gocyclo -over 25 .
entry: gocyclo -over 26 .
language: system
pass_filenames: false
files: \.go$
Expand Down
12 changes: 10 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# hadolint global ignore=DL3047,DL4001
# hadolint global ignore=DL3047,DL4001,DL4006

Check failure on line 1 in Dockerfile

View workflow job for this annotation

GitHub Actions / 🐋 Dockerfile Security Scan

CKV_DOCKER_3: "Ensure that a user for the container has been created"

Check failure on line 1 in Dockerfile

View workflow job for this annotation

GitHub Actions / 🐋 Dockerfile Security Scan

CKV_DOCKER_2: "Ensure that HEALTHCHECK instructions have been added to container images"
# Multi-stage build for Go application
FROM golang:1.25.3-alpine3.22 AS builder

Expand Down Expand Up @@ -44,6 +44,12 @@
RUN wget -O /cyclonedx "https://github.com/CycloneDX/cyclonedx-cli/releases/download/v0.27.2/cyclonedx-linux-x64" && \
chmod +x /cyclonedx

# Install Trivy
# Download the static binary directly since we're using distroless
RUN TRIVY_VERSION=$(wget -qO- "https://api.github.com/repos/aquasecurity/trivy/releases/latest" | grep '"tag_name":' | sed -E 's/.*"v([^"]+)".*/\1/') && \
wget -qO- "https://github.com/aquasecurity/trivy/releases/download/v${TRIVY_VERSION}/trivy_${TRIVY_VERSION}_Linux-64bit.tar.gz" | tar -xzf - -C /usr/local/bin trivy && \
chmod +x /usr/local/bin/trivy

# Runtime stage - Distroless
FROM gcr.io/distroless/static-debian12:nonroot

Expand All @@ -56,6 +62,7 @@
COPY --from=tools /usr/local/aws-cli /usr/local/aws-cli
COPY --from=tools /usr/local/bin/aws /usr/local/bin/aws
COPY --from=tools /cyclonedx /usr/local/bin/cyclonedx
COPY --from=tools /usr/local/bin/trivy /usr/local/bin/trivy

# Copy the binary from builder
COPY --from=builder /build/clickbom /app/clickbom
Expand All @@ -69,7 +76,8 @@
# distroless runs as nonroot user by default (UID 65532)
# Set environment
ENV PATH="/usr/local/bin:$PATH" \
TEMP_DIR="/tmp"
TEMP_DIR="/tmp" \
TRIVY_CACHE_DIR="/tmp/.trivy"

# Run the application
ENTRYPOINT ["/app/clickbom"]
39 changes: 25 additions & 14 deletions action.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
name: 'ClickBOM'
description: 'Download SBOMs from GitHub, Mend, and Wiz. Convert to CycloneDX and SPDX formats. Upload to S3 and ClickHouse.'
description: 'Download SBOMs from GitHub, Mend, Wiz, and Trivy. Convert to CycloneDX and SPDX formats. Upload to S3 and ClickHouse.'
author: 'ClickHouse, Inc.'
inputs:
# GitHub-specific inputs
Expand Down Expand Up @@ -59,17 +59,25 @@ inputs:
wiz-report-id:
description: 'Wiz report ID to download'
required: false
# AWS-specific inputs
aws-access-key-id:
description: 'AWS Access Key ID'
required: true
aws-secret-access-key:
description: 'AWS Secret Access Key'
required: true
aws-region:
description: 'AWS region'
# Trivy-specific inputs
trivy-image:
description: 'Container image to scan with Trivy for SBOM generation (format: registry/repo:tag or ECR URI)'
required: false
trivy-ecr-account-id:
description: 'AWS Account ID where ECR repository is located (for cross-account access)'
required: false
trivy-ecr-region:
description: 'AWS region where ECR repository is located'
required: false
default: 'us-east-1'
trivy-ecr-role-arn:
description: 'IAM role ARN to assume for ECR access (for cross-account)'
required: false
trivy-format:
description: 'Trivy SBOM output format: cyclonedx or spdxjson'
required: false
default: 'cyclonedx'
# AWS-specific inputs
s3-bucket:
description: 'S3 bucket name'
required: true
Expand Down Expand Up @@ -103,7 +111,7 @@ inputs:
default: 'false'
# General inputs
sbom-source:
description: 'SBOM source: github or mend'
description: 'SBOM source: github, mend, wiz, or trivy'
required: false
default: 'github'
sbom-format:
Expand Down Expand Up @@ -150,10 +158,13 @@ runs:
WIZ_CLIENT_ID: ${{ inputs.wiz-client-id }}
WIZ_CLIENT_SECRET: ${{ inputs.wiz-client-secret }}
WIZ_REPORT_ID: ${{ inputs.wiz-report-id }}
# Trivy-specific
TRIVY_IMAGE: ${{ inputs.trivy-image }}
TRIVY_ECR_ACCOUNT_ID: ${{ inputs.trivy-ecr-account-id }}
TRIVY_ECR_REGION: ${{ inputs.trivy-ecr-region }}
TRIVY_ECR_ROLE_ARN: ${{ inputs.trivy-ecr-role-arn }}
TRIVY_FORMAT: ${{ inputs.trivy-format }}
# AWS-specific
AWS_ACCESS_KEY_ID: ${{ inputs.aws-access-key-id }}
AWS_SECRET_ACCESS_KEY: ${{ inputs.aws-secret-access-key }}
AWS_DEFAULT_REGION: ${{ inputs.aws-region }}
S3_BUCKET: ${{ inputs.s3-bucket }}
S3_KEY: ${{ inputs.s3-key }}
# ClickHouse-specific
Expand Down
153 changes: 139 additions & 14 deletions cmd/clickbom/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"context"
"fmt"
"os"
"path"
"path/filepath"
"strings"

Expand Down Expand Up @@ -45,7 +46,7 @@ func run() error {
}()

// Initialize S3 client
s3Client, err := storage.NewS3Client(ctx, cfg.AWSAccessKeyID, cfg.AWSSecretAccessKey, cfg.AWSRegion)
s3Client, err := storage.NewS3Client(ctx)
if err != nil {
return fmt.Errorf("failed to create S3 client: %w", err)
}
Expand All @@ -64,7 +65,7 @@ func handleNormalMode(ctx context.Context, cfg *config.Config, s3Client *storage
extractedSBOM := filepath.Join(tempDir, "extracted_sbom.json")
processedSBOM := filepath.Join(tempDir, "processed_sbom.json")

// Download SBOM based on source
// Download/Generate SBOM based on source
switch cfg.SBOMSource {
case "github":
logger.Info("Downloading SBOM from GitHub")
Expand All @@ -87,11 +88,21 @@ func handleNormalMode(ctx context.Context, cfg *config.Config, s3Client *storage
return fmt.Errorf("failed to download Wiz SBOM: %w", err)
}

case "trivy":
logger.Info("Generating SBOM with Trivy")
trivyClient, err := sbom.NewTrivyClient(ctx, cfg)
if err != nil {
return fmt.Errorf("failed to create Trivy client: %w", err)
}
if err := trivyClient.GenerateSBOM(ctx, originalSBOM); err != nil {
return fmt.Errorf("failed to generate SBOM with Trivy: %w", err)
}

default:
return fmt.Errorf("unsupported SBOM source: %s", cfg.SBOMSource)
}

// Extract from wrapper if needed
// Extract SBOM from wrapper if needed (mainly for GitHub)
if err := sbom.ExtractSBOMFromWrapper(originalSBOM, extractedSBOM); err != nil {
return fmt.Errorf("failed to extract SBOM: %w", err)
}
Expand All @@ -103,9 +114,9 @@ func handleNormalMode(ctx context.Context, cfg *config.Config, s3Client *storage
}
logger.Info("Detected SBOM format: %s", detectedFormat)

// Convert to desired format
targetFormat := sbom.Format(cfg.SBOMFormat)
if err := sbom.ConvertSBOM(extractedSBOM, processedSBOM, detectedFormat, targetFormat); err != nil {
// Convert to desired format if needed
desiredFormat := sbom.Format(cfg.SBOMFormat)
if err := sbom.ConvertSBOM(extractedSBOM, processedSBOM, detectedFormat, desiredFormat); err != nil {
return fmt.Errorf("failed to convert SBOM: %w", err)
}

Expand All @@ -115,28 +126,133 @@ func handleNormalMode(ctx context.Context, cfg *config.Config, s3Client *storage
}

logger.Success("SBOM processing completed successfully!")
logger.Info("SBOM available at: s3://%s/%s", cfg.S3Bucket, cfg.S3Key)

// ClickHouse operations
// ClickHouse upload if configured
if cfg.ClickHouseURL != "" {
if err := handleClickHouse(ctx, cfg, processedSBOM); err != nil {
return fmt.Errorf("ClickHouse error: %w", err)
logger.Info("Uploading SBOM data to ClickHouse")

chClient, err := storage.NewClickHouseClient(cfg)
if err != nil {
return fmt.Errorf("failed to create ClickHouse client: %w", err)
}

tableName := generateTableName(cfg)

if err := chClient.SetupTable(ctx, tableName); err != nil {
return fmt.Errorf("failed to setup table: %w", err)
}

if err := chClient.InsertSBOMData(ctx, processedSBOM, tableName, cfg.SBOMFormat); err != nil {
return fmt.Errorf("failed to upload to ClickHouse: %w", err)
}

logger.Success("ClickHouse operations completed successfully!")
}

return nil
}

func handleMergeMode(_ context.Context, _ *config.Config, _ *storage.S3Client, _ string) error {
func handleMergeMode(ctx context.Context, cfg *config.Config, s3Client *storage.S3Client, tempDir string) error {
logger.Info("Running in MERGE mode - merging all CycloneDX SBOMs from S3")

// Implementation for merge mode...
// This would involve downloading all SBOMs from S3, merging them, and uploading
// Create download directory
downloadDir := filepath.Join(tempDir, "downloads")
if err := os.MkdirAll(downloadDir, 0755); err != nil {
return fmt.Errorf("failed to create download directory: %w", err)
}

// Download all files from S3
downloadedFiles, err := s3Client.DownloadAll(ctx, cfg.S3Bucket, "", downloadDir)
if err != nil {
return fmt.Errorf("failed to download files from S3: %w", err)
}

logger.Info("Downloaded %d files from S3", len(downloadedFiles))

if len(downloadedFiles) == 0 {
return fmt.Errorf("no files found in S3 bucket: %s", cfg.S3Bucket)
}

// Filter and validate CycloneDX SBOMs
cyclonedxFiles := make([]string, 0)

for _, file := range downloadedFiles {
filename := filepath.Base(file)

// Apply include/exclude filters
if !sbom.ShouldIncludeFile(filename, cfg.Include, cfg.Exclude) {
logger.Debug("Skipping %s due to include/exclude filters", filename)
continue
}

// Check if file is valid CycloneDX
format, err := sbom.DetectSBOMFormat(file)
if err != nil {
logger.Warning("Failed to detect format for %s: %v", filename, err)
continue
}

if format != sbom.FormatCycloneDX {
logger.Debug("Skipping %s: not CycloneDX format (detected: %s)", filename, format)
continue
}

cyclonedxFiles = append(cyclonedxFiles, file)
logger.Debug("Added %s to merge list", filename)
}

logger.Info("Found %d valid CycloneDX SBOMs to merge", len(cyclonedxFiles))

if len(cyclonedxFiles) == 0 {
return fmt.Errorf("no valid CycloneDX SBOMs found after filtering")
}

// Merge all SBOMs
mergedSBOM := filepath.Join(tempDir, "merged_sbom.json")
if err := sbom.MergeSBOMs(cyclonedxFiles, mergedSBOM); err != nil {
return fmt.Errorf("failed to merge SBOMs: %w", err)
}

// Convert to desired format if needed
finalSBOM := filepath.Join(tempDir, "final_sbom.json")
desiredFormat := sbom.Format(cfg.SBOMFormat)
if err := sbom.ConvertSBOM(mergedSBOM, finalSBOM, sbom.FormatCycloneDX, desiredFormat); err != nil {
return fmt.Errorf("failed to convert merged SBOM: %w", err)
}

// Upload merged SBOM back to S3
if err := s3Client.Upload(ctx, finalSBOM, cfg.S3Bucket, cfg.S3Key, cfg.SBOMFormat); err != nil {
return fmt.Errorf("failed to upload merged SBOM: %w", err)
}

logger.Success("SBOM merging and upload completed successfully!")

// ClickHouse upload if configured
if cfg.ClickHouseURL != "" {
logger.Info("Uploading merged SBOM data to ClickHouse")

chClient, err := storage.NewClickHouseClient(cfg)
if err != nil {
return fmt.Errorf("failed to create ClickHouse client: %w", err)
}

tableName := generateTableName(cfg)

if err := chClient.SetupTable(ctx, tableName); err != nil {
return fmt.Errorf("failed to setup table: %w", err)
}

if err := chClient.InsertSBOMData(ctx, finalSBOM, tableName, cfg.SBOMFormat); err != nil {
return fmt.Errorf("failed to upload to ClickHouse: %w", err)
}

logger.Success("ClickHouse operations completed successfully!")
}

return nil
}

func handleClickHouse(ctx context.Context, cfg *config.Config, sbomFile string) error {
func handleClickHouse(ctx context.Context, cfg *config.Config, sbomFile string) error { // nolint: unused
logger.Info("Starting ClickHouse operations")

chClient, err := storage.NewClickHouseClient(cfg)
Expand All @@ -159,6 +275,10 @@ func handleClickHouse(ctx context.Context, cfg *config.Config, sbomFile string)
}

func generateTableName(cfg *config.Config) string {
if cfg.Merge {
replacer := strings.NewReplacer(".", "_", "-", "_")
return fmt.Sprintf("merged_%s", replacer.Replace(cfg.S3Key))
}
switch cfg.SBOMSource {
case "github":
return strings.ReplaceAll(strings.ToLower(cfg.Repository), "/", "_")
Expand All @@ -170,6 +290,11 @@ func generateTableName(cfg *config.Config) string {
return fmt.Sprintf("mend_%s", strings.ReplaceAll(uuid, "-", "_"))
case "wiz":
return fmt.Sprintf("wiz_%s", strings.ReplaceAll(cfg.WizReportID, "-", "_"))
case "trivy":
result := path.Base(cfg.TrivyImage)
replacer := strings.NewReplacer(":", "_", ".", "_", "-", "_")
result = replacer.Replace(result)
return fmt.Sprintf("trivy_%s", result)
default:
return "sbom_data"
}
Expand Down
5 changes: 3 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@ go 1.25.3
require (
github.com/aws/aws-sdk-go-v2 v1.39.4
github.com/aws/aws-sdk-go-v2/config v1.31.15
github.com/aws/aws-sdk-go-v2/credentials v1.18.19
github.com/aws/aws-sdk-go-v2/service/s3 v1.88.7
github.com/aws/aws-sdk-go-v2/service/sts v1.38.9
github.com/google/uuid v1.6.0
)

require (
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.2 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.18.19 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.11 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.11 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.11 // indirect
Expand All @@ -22,6 +24,5 @@ require (
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.11 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.29.8 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.3 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.38.9 // indirect
github.com/aws/smithy-go v1.23.1 // indirect
)
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,5 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.38.9 h1:Ekml5vGg6sHSZLZJQJagefnVe6Pm
github.com/aws/aws-sdk-go-v2/service/sts v1.38.9/go.mod h1:/e15V+o1zFHWdH3u7lpI3rVBcxszktIKuHKCY2/py+k=
github.com/aws/smithy-go v1.23.1 h1:sLvcH6dfAFwGkHLZ7dGiYF7aK6mg4CgKA/iDKjLDt9M=
github.com/aws/smithy-go v1.23.1/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
Loading
Loading