From 7135169d95fb57742d324aa0c82e363c9f3ab6d4 Mon Sep 17 00:00:00 2001 From: Vishnu Bharathi Date: Fri, 26 Sep 2025 21:14:15 +0530 Subject: [PATCH 1/3] feat: support multiple domain associations --- upload-file/main.go | 2 +- upload-file/scan/associate_product_domain.go | 22 ++- .../scan/associate_product_domain_test.go | 130 ++++++++++++++++-- 3 files changed, 136 insertions(+), 18 deletions(-) diff --git a/upload-file/main.go b/upload-file/main.go index 2837759..3ef344d 100644 --- a/upload-file/main.go +++ b/upload-file/main.go @@ -45,7 +45,7 @@ func main() { log.Printf("Associated image name: %s\n", imageName) } - domain, err := sc.AssociateProductDomain(context.Background()) + domain, err := sc.AssociateProductDomains(context.Background()) switch { case err != nil: pd, _ := catalog.ProductDomains(context.Background(), secAgentClient) diff --git a/upload-file/scan/associate_product_domain.go b/upload-file/scan/associate_product_domain.go index 950ef7a..6e945d4 100644 --- a/upload-file/scan/associate_product_domain.go +++ b/upload-file/scan/associate_product_domain.go @@ -2,12 +2,13 @@ package scan import ( "context" + "errors" + "strings" "github.com/machinebox/graphql" ) -func (s *Scan) AssociateProductDomain(ctx context.Context) (string, error) { - productDomain := s.Tags["product_domain"] +func (s *Scan) associateProductDomain(ctx context.Context, productDomain string) (string, error) { if productDomain == "" { return "", nil } @@ -34,3 +35,20 @@ func (s *Scan) AssociateProductDomain(ctx context.Context) (string, error) { return productDomain, s.client.ExecuteGQL(ctx, req, &response) } + +func (s *Scan) AssociateProductDomains(ctx context.Context) (string, error) { + productDomain := s.Tags["product_domain"] + if productDomain == "" { + return "", nil + } + + var errs []error + for _, pd := range strings.Split(productDomain, ",") { + _, err := s.associateProductDomain(ctx, strings.TrimSpace(pd)) + if err != nil { + errs = append(errs, err) + } + } + + return productDomain, errors.Join(errs...) +} diff --git a/upload-file/scan/associate_product_domain_test.go b/upload-file/scan/associate_product_domain_test.go index dcf4ca0..4bd0e17 100644 --- a/upload-file/scan/associate_product_domain_test.go +++ b/upload-file/scan/associate_product_domain_test.go @@ -10,8 +10,8 @@ import ( "github.com/hasura/security-agent-tools/upload-file/saclient" ) -func TestScan_AssociateProductDomain_Success(t *testing.T) { - expectedProductDomain := "ecommerce" +func TestScan_AssociateProductDomains_Success(t *testing.T) { + expectedProductDomain := "hasura-v2-cloud-control-plane" // Mock GraphQL server server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -38,10 +38,10 @@ func TestScan_AssociateProductDomain_Success(t *testing.T) { client: client, } - productDomain, err := scan.AssociateProductDomain(context.Background()) + productDomain, err := scan.AssociateProductDomains(context.Background()) if err != nil { - t.Errorf("AssociateProductDomain failed: %v", err) + t.Errorf("AssociateProductDomains failed: %v", err) } if productDomain != expectedProductDomain { @@ -49,7 +49,7 @@ func TestScan_AssociateProductDomain_Success(t *testing.T) { } } -func TestScan_AssociateProductDomain_EmptyProductDomain(t *testing.T) { +func TestScan_AssociateProductDomains_EmptyProductDomain(t *testing.T) { client := saclient.NewClient("https://example.com/graphql", "test-api-key") scan := &Scan{ ID: "scan-id-123", @@ -57,10 +57,10 @@ func TestScan_AssociateProductDomain_EmptyProductDomain(t *testing.T) { client: client, } - productDomain, err := scan.AssociateProductDomain(context.Background()) + productDomain, err := scan.AssociateProductDomains(context.Background()) if err != nil { - t.Errorf("AssociateProductDomain failed: %v", err) + t.Errorf("AssociateProductDomains failed: %v", err) } if productDomain != "" { @@ -68,7 +68,7 @@ func TestScan_AssociateProductDomain_EmptyProductDomain(t *testing.T) { } } -func TestScan_AssociateProductDomain_GraphQLError(t *testing.T) { +func TestScan_AssociateProductDomains_GraphQLError(t *testing.T) { // Mock GraphQL server that returns an error server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") @@ -81,24 +81,124 @@ func TestScan_AssociateProductDomain_GraphQLError(t *testing.T) { scan := &Scan{ ID: "scan-id-123", Tags: map[string]string{ - "product_domain": "ecommerce", + "product_domain": "hasura-ddn-control-plane", }, client: client, } - productDomain, err := scan.AssociateProductDomain(context.Background()) + productDomain, err := scan.AssociateProductDomains(context.Background()) if err == nil { t.Error("Expected error for GraphQL error response") } - if productDomain != "ecommerce" { + if productDomain != "hasura-ddn-control-plane" { t.Errorf("Expected product domain to be returned even on error, got %s", productDomain) } } +func TestScan_AssociateProductDomains_MultipleDomainsSuccess(t *testing.T) { + expectedProductDomains := "hasura-v2-cloud-control-plane, hasura-v2-cloud-data-plane, hasura-ddn-control-plane" + callCount := 0 + + // Mock GraphQL server that expects 3 calls + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + callCount++ + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{ + "data": { + "insert_vulnerability_reports_by_product_domains": { + "returning": [{ + "id": "assoc-id-456" + }] + } + } + }`)) + })) + defer server.Close() + + client := saclient.NewClient(server.URL, "test-api-key") + scan := &Scan{ + ID: "scan-id-123", + Tags: map[string]string{ + "product_domain": expectedProductDomains, + }, + client: client, + } + + productDomain, err := scan.AssociateProductDomains(context.Background()) + + if err != nil { + t.Errorf("AssociateProductDomains failed: %v", err) + } + + if productDomain != expectedProductDomains { + t.Errorf("Expected product domain %s, got %s", expectedProductDomains, productDomain) + } + + // Should have made 3 GraphQL calls (one for each domain) + if callCount != 3 { + t.Errorf("Expected 3 GraphQL calls, got %d", callCount) + } +} + +func TestScan_AssociateProductDomains_MultipleDomainsPartialError(t *testing.T) { + expectedProductDomains := "hasura-v2-cloud-control-plane, hasura-v2-cloud-data-plane, hasura-ddn-control-plane" + callCount := 0 + + // Mock GraphQL server that fails on the second call + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + callCount++ + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + + if callCount == 2 { + // Fail on second call (hasura-v2-cloud-data-plane) + w.Write([]byte(`{"errors": [{"message": "Database error"}]}`)) + } else { + w.Write([]byte(`{ + "data": { + "insert_vulnerability_reports_by_product_domains": { + "returning": [{ + "id": "assoc-id-456" + }] + } + } + }`)) + } + })) + defer server.Close() + + client := saclient.NewClient(server.URL, "test-api-key") + scan := &Scan{ + ID: "scan-id-123", + Tags: map[string]string{ + "product_domain": expectedProductDomains, + }, + client: client, + } + + productDomain, err := scan.AssociateProductDomains(context.Background()) + + // Should return an error due to the failed second call + if err == nil { + t.Error("Expected error due to partial failure") + } + + // Should still return the original product domain string + if productDomain != expectedProductDomains { + t.Errorf("Expected product domain %s, got %s", expectedProductDomains, productDomain) + } + + // Should have made 3 GraphQL calls (one for each domain) + if callCount != 3 { + t.Errorf("Expected 3 GraphQL calls, got %d", callCount) + } +} + func TestProductDomains_Success(t *testing.T) { - expectedDomains := []string{"ecommerce", "analytics", "security"} + expectedDomains := []string{"hasura-v2-cloud-control-plane", "hasura-v2-cloud-data-plane", "hasura-ddn-control-plane"} // Mock GraphQL server server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -107,9 +207,9 @@ func TestProductDomains_Success(t *testing.T) { w.Write([]byte(`{ "data": { "vulnerability_reports_product_domains": [ - {"code": "ecommerce"}, - {"code": "analytics"}, - {"code": "security"} + {"code": "hasura-v2-cloud-control-plane"}, + {"code": "hasura-v2-cloud-data-plane"}, + {"code": "hasura-ddn-control-plane"} ] } }`)) From 5e0dd3596e1db38fc6a9a2b7da7ee2b456483928 Mon Sep 17 00:00:00 2001 From: Vishnu Bharathi Date: Mon, 29 Sep 2025 12:55:51 +0530 Subject: [PATCH 2/3] add test for multiple domains --- .github/workflows/test-upload-file.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/test-upload-file.yml b/.github/workflows/test-upload-file.yml index cdf889c..2b69884 100644 --- a/.github/workflows/test-upload-file.yml +++ b/.github/workflows/test-upload-file.yml @@ -69,6 +69,14 @@ jobs: tags: | product_domain=security-agent-test + - name: Test multiple domain associations (valid) + uses: ./upload-file + with: + file_path: test-upload.json + security_agent_api_key: ${{ secrets.SECURITY_AGENT_API_KEY }} + tags: | + product_domain=security-agent-test-1,security-agent-test-2 + - name: Test service name association uses: ./upload-file with: From 4cc284d02b5466893355c2568a6cd471dd61064e Mon Sep 17 00:00:00 2001 From: Vishnu Bharathi Date: Mon, 29 Sep 2025 13:01:35 +0530 Subject: [PATCH 3/3] fix readme and logging --- README.md | 23 ++--------------------- upload-file/main.go | 2 +- 2 files changed, 3 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 573cda2..93a46f0 100644 --- a/README.md +++ b/README.md @@ -33,9 +33,7 @@ The `upload-file` action uploads a file to the Security Agent. It can be used to metadata_upload_path: '' ``` -#### Service-based Upload - -You can use `service=` in `tags` to upload the metadata file to a service directory. The metadata will be uploaded to `metadata/services/`. +#### Usage This is compatible to run with all event types in GitHub Actions: `push`, `pull_request`, `schedule`, `workflow_dispatch`. That means that, you can use the GitHub action to store security scan reports from pull request builds, branch builds, cron builds, and manual builds. @@ -82,24 +80,6 @@ jobs: scanner=trivy ``` -#### Custom Upload - -If your use-case doesn't fit in the usual "service-based upload" described above, you can have more customization in which you are uploading the file to. You can use `metadata_upload_path` to specify the path to upload the metadata file. - -```yaml -- name: Upload Trivy scan results to Security Agent - uses: hasura/security-agent-tools/upload-file@v1 - with: - file_path: trivy-results.json - security_agent_api_key: ${{ secrets.SECURITY_AGENT_API_KEY }} - metadata_upload_path: | - lts-image-scans/${{ steps.timestamp.outputs.value }}/${{ steps.artifact-name.outputs.name }} - tags: | - scanner=trivy - image_name=${{ matrix.service.image }} -``` - -This will upload the metadata file to `metadata/lts-image-scans///*` path whenever invoked from a GitHub workflow. ## Buildkite @@ -132,6 +112,7 @@ EOF -e BUILDKITE_BRANCH \ -e BUILDKITE_TAG \ -e BUILDKITE_PULL_REQUEST \ + -e GITHUB_REPOSITORY \ ghcr.io/hasura/security-agent-tools/upload-file:v1 } diff --git a/upload-file/main.go b/upload-file/main.go index 3ef344d..58e23d2 100644 --- a/upload-file/main.go +++ b/upload-file/main.go @@ -55,7 +55,7 @@ func main() { } log.Fatalf("Failed to associate product domain with scan: %v. Please check `product_domain` value is one of the following:\n%s", err, pds.String()) case domain != "": - log.Printf("Associated product domain: %s\n", domain) + log.Printf("Associated product domain(s): %s\n", domain) } serviceName, err := sc.AssociateServiceName(context.Background())