From 6087bc922f54341911848c3cfc117179ba159cbd Mon Sep 17 00:00:00 2001 From: Sahil Bansal Date: Thu, 28 May 2026 19:12:12 +0530 Subject: [PATCH] feat: populate CI invocation context on cloud events (#304) * feat: add CloudSinkEnvResolver interface with default implementation Co-Authored-By: Claude Opus 4.6 * feat: add GitHub Actions environment resolver for cloud sink Co-Authored-By: Claude Opus 4.6 * feat: populate invocation context with CI environment on cloud events Co-Authored-By: Claude Opus 4.6 * fix: address lint errors in cloud sink tests Co-Authored-By: Claude Opus 4.6 * refactor: use getter-based CloudSinkCIResolver with nil-when-no-CI Rename to CloudSinkCIResolver with focused CI concern. Factory returns nil when no CI is detected, removing the need for IsCI() and a default resolver. Leaves room for a separate agent resolver in the future. Co-Authored-By: Claude Opus 4.6 * feat: add CI metadata support using updated API SDK Update SDK to include SetMetadata on EndpointCIContext. Add Metadata() to CloudSinkCIResolver interface and GitHub Actions implementation (workflow, job, run_attempt, server_url). Wire metadata into buildInvocationContext. Co-Authored-By: Claude Opus 4.6 * refactor: address review comments on CI resolver - Inject CloudSinkCIResolver as dependency into newCloudSink for testability - Check both GITHUB_ACTIONS and GITHUB_RUN_ID for GHA environment detection - Make factory and constructor package-private (newCloudSinkCIResolver, newGithubActionsCIResolver) - Attach invocation context only to session complete events, not every event Co-Authored-By: Claude Opus 4.6 * fix: fail fast on os.Getwd error instead of swallowing it Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- go.mod | 8 +- go.sum | 28 +++---- internal/audit/audit.go | 2 +- internal/audit/cloud_env_resolver.go | 46 ++++++++++ internal/audit/cloud_env_resolver_github.go | 64 ++++++++++++++ .../audit/cloud_env_resolver_github_test.go | 83 +++++++++++++++++++ internal/audit/cloud_env_resolver_test.go | 36 ++++++++ internal/audit/cloud_sink.go | 61 +++++++++++++- internal/audit/cloud_sink_test.go | 57 +++++++++++++ 9 files changed, 365 insertions(+), 20 deletions(-) create mode 100644 internal/audit/cloud_env_resolver.go create mode 100644 internal/audit/cloud_env_resolver_github.go create mode 100644 internal/audit/cloud_env_resolver_github_test.go create mode 100644 internal/audit/cloud_env_resolver_test.go diff --git a/go.mod b/go.mod index c78e939..30f08a3 100644 --- a/go.mod +++ b/go.mod @@ -3,8 +3,8 @@ module github.com/safedep/pmg go 1.25.1 require ( - buf.build/gen/go/safedep/api/grpc/go v1.6.1-20260507092425-ac47f9a19339.1 - buf.build/gen/go/safedep/api/protocolbuffers/go v1.36.11-20260507092425-ac47f9a19339.1 + buf.build/gen/go/safedep/api/grpc/go v1.6.2-20260528074646-b9e182189444.1 + buf.build/gen/go/safedep/api/protocolbuffers/go v1.36.11-20260528074646-b9e182189444.1 github.com/Masterminds/semver v1.5.0 github.com/elazarl/goproxy v1.8.1 github.com/fatih/color v1.18.0 @@ -26,7 +26,7 @@ require ( golang.org/x/sync v0.20.0 golang.org/x/sys v0.43.0 golang.org/x/term v0.42.0 - google.golang.org/grpc v1.80.0 + google.golang.org/grpc v1.81.0 google.golang.org/protobuf v1.36.11 gopkg.in/yaml.v3 v3.0.1 ) @@ -85,7 +85,7 @@ require ( golang.org/x/mod v0.33.0 // indirect golang.org/x/net v0.51.0 // indirect golang.org/x/text v0.35.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect kernel.org/pub/linux/libs/security/libcap/psx v1.2.77 // indirect diff --git a/go.sum b/go.sum index fefa5ad..6930c01 100644 --- a/go.sum +++ b/go.sum @@ -2,10 +2,10 @@ al.essio.dev/pkg/shellescape v1.5.1 h1:86HrALUujYS/h+GtqoB26SBEdkWfmMI6FubjXlsXy al.essio.dev/pkg/shellescape v1.5.1/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890= buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20240508200655-46a4cf4ba109.1 h1:NXwdBG3BiC6xWH4iG3csbT+JHF9u1jl5ThDhumCnKnk= buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20240508200655-46a4cf4ba109.1/go.mod h1:tvtbpgaVXZX4g6Pn+AnzFycuRK3MOz5HJfEGeEllXYM= -buf.build/gen/go/safedep/api/grpc/go v1.6.1-20260507092425-ac47f9a19339.1 h1:TgL0Xu+EFQhr68C/eGrflL+TaFtEUWkfWXQ+f+vWGdg= -buf.build/gen/go/safedep/api/grpc/go v1.6.1-20260507092425-ac47f9a19339.1/go.mod h1:AU7tshd3hSyemWEYn6mofmWPUJaWmGExFHAQSjp6N4o= -buf.build/gen/go/safedep/api/protocolbuffers/go v1.36.11-20260507092425-ac47f9a19339.1 h1:JRzHMhoJg1Mlae+PR+ZZ1I1aaqAJdyF4WY3CidJD/us= -buf.build/gen/go/safedep/api/protocolbuffers/go v1.36.11-20260507092425-ac47f9a19339.1/go.mod h1:I8E+sZXJNqzWBtSlRGCoiEorLSRiix50h2R/66aBzME= +buf.build/gen/go/safedep/api/grpc/go v1.6.2-20260528074646-b9e182189444.1 h1:zpjFPeuPS4AdzfOMlwDSVWwxrRBrkL0ul0gV65RYzh8= +buf.build/gen/go/safedep/api/grpc/go v1.6.2-20260528074646-b9e182189444.1/go.mod h1:8pVZh4owzo4YXcKvFvdWEYGr4k/1VHGR0h39XHsuHD4= +buf.build/gen/go/safedep/api/protocolbuffers/go v1.36.11-20260528074646-b9e182189444.1 h1:2Ws+lb98zkYNJ4dwRbjJYviSY5mI80eA+GJc3NyY+rc= +buf.build/gen/go/safedep/api/protocolbuffers/go v1.36.11-20260528074646-b9e182189444.1/go.mod h1:I8E+sZXJNqzWBtSlRGCoiEorLSRiix50h2R/66aBzME= cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= @@ -26,8 +26,8 @@ github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEX github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= -github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w= -github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI= +github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik= +github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4= github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= @@ -47,11 +47,11 @@ github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymF github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= -github.com/envoyproxy/go-control-plane/envoy v1.36.0 h1:yg/JjO5E7ubRyKX3m07GF3reDNEnfOboJ0QySbH736g= -github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98= +github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ= +github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= -github.com/envoyproxy/protoc-gen-validate v1.3.0 h1:TvGH1wof4H33rezVKWSpqKz5NXWg5VPuZ0uONDT6eb4= -github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA= +github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds= +github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= @@ -307,15 +307,15 @@ google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7 google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20200423170343-7949de9c1215/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 h1:sNrWoksmOyF5bvJUcnmbeAmQi8baNhqg5IWaI3llQqU= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= -google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= -google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= +google.golang.org/grpc v1.81.0 h1:W3G9N3KQf3BU+YuCtGKJk0CmxQNbAISICD/9AORxLIw= +google.golang.org/grpc v1.81.0/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/audit/audit.go b/internal/audit/audit.go index 83a1f34..ac1e51a 100644 --- a/internal/audit/audit.go +++ b/internal/audit/audit.go @@ -28,7 +28,7 @@ func Initialize(cfg *config.RuntimeConfig) error { sinks = append(sinks, newEventlogSink()) if cfg.Config.Cloud.Enabled && !analytics.IsDisabled() { - cs, err := newCloudSink(cfg) + cs, err := newCloudSink(cfg, newCloudSinkCIResolver()) if err != nil { log.Warnf("Cloud sync initialization failed: %v", err) } else { diff --git a/internal/audit/cloud_env_resolver.go b/internal/audit/cloud_env_resolver.go new file mode 100644 index 0000000..de25d33 --- /dev/null +++ b/internal/audit/cloud_env_resolver.go @@ -0,0 +1,46 @@ +package audit + +import ( + "os" + + controltowerv1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/controltower/v1" +) + +// CloudSinkCIResolver resolves CI/CD environment context for the cloud +// sink. Implementations detect CI providers from environment variables +// and expose individual fields. The cloudSink assembles the +// EndpointCIContext proto from these. +type CloudSinkCIResolver interface { + // Provider returns the detected CI provider. + Provider() controltowerv1.EndpointCIProvider + + // RunId returns the CI run identifier. + RunId() string + + // Repository returns the repository being built. + Repository() string + + // Branch returns the branch being built. + Branch() string + + // CommitSha returns the commit SHA being built. + CommitSha() string + + // Actor returns the user or bot that triggered the build. + Actor() string + + // PrNumber returns the pull request number, if applicable. + PrNumber() string + + // Metadata returns provider-specific key-value pairs. + Metadata() map[string]string +} + +// newCloudSinkCIResolver detects the CI environment and returns the +// appropriate resolver. Returns nil when no CI provider is detected. +func newCloudSinkCIResolver() CloudSinkCIResolver { + if os.Getenv("GITHUB_ACTIONS") != "" && os.Getenv("GITHUB_RUN_ID") != "" { + return newGithubActionsCIResolver() + } + return nil +} diff --git a/internal/audit/cloud_env_resolver_github.go b/internal/audit/cloud_env_resolver_github.go new file mode 100644 index 0000000..a9bd9fb --- /dev/null +++ b/internal/audit/cloud_env_resolver_github.go @@ -0,0 +1,64 @@ +package audit + +import ( + "os" + "regexp" + + controltowerv1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/controltower/v1" +) + +var prRefPattern = regexp.MustCompile(`^refs/pull/(\d+)/merge$`) + +type githubActionsEnvResolver struct{} + +func newGithubActionsCIResolver() CloudSinkCIResolver { + return &githubActionsEnvResolver{} +} + +func (r *githubActionsEnvResolver) Provider() controltowerv1.EndpointCIProvider { + return controltowerv1.EndpointCIProvider_ENDPOINT_CI_PROVIDER_GITHUB_ACTIONS +} + +func (r *githubActionsEnvResolver) RunId() string { return os.Getenv("GITHUB_RUN_ID") } +func (r *githubActionsEnvResolver) Repository() string { return os.Getenv("GITHUB_REPOSITORY") } +func (r *githubActionsEnvResolver) CommitSha() string { return os.Getenv("GITHUB_SHA") } +func (r *githubActionsEnvResolver) Actor() string { return os.Getenv("GITHUB_ACTOR") } + +func (r *githubActionsEnvResolver) Branch() string { + if headRef := os.Getenv("GITHUB_HEAD_REF"); headRef != "" { + return headRef + } + return os.Getenv("GITHUB_REF_NAME") +} + +func (r *githubActionsEnvResolver) PrNumber() string { + matches := prRefPattern.FindStringSubmatch(os.Getenv("GITHUB_REF")) + if len(matches) == 2 { + return matches[1] + } + return "" +} + +func (r *githubActionsEnvResolver) Metadata() map[string]string { + entries := []struct { + key string + envVar string + }{ + {"workflow", "GITHUB_WORKFLOW"}, + {"job", "GITHUB_JOB"}, + {"run_attempt", "GITHUB_RUN_ATTEMPT"}, + {"server_url", "GITHUB_SERVER_URL"}, + } + + metadata := make(map[string]string) + for _, e := range entries { + if val := os.Getenv(e.envVar); val != "" { + metadata[e.key] = val + } + } + + if len(metadata) == 0 { + return nil + } + return metadata +} diff --git a/internal/audit/cloud_env_resolver_github_test.go b/internal/audit/cloud_env_resolver_github_test.go new file mode 100644 index 0000000..72685e1 --- /dev/null +++ b/internal/audit/cloud_env_resolver_github_test.go @@ -0,0 +1,83 @@ +package audit + +import ( + "testing" + + controltowerv1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/controltower/v1" + "github.com/stretchr/testify/assert" +) + +func TestGithubActionsResolverBasicFields(t *testing.T) { + t.Setenv("GITHUB_RUN_ID", "9876543210") + t.Setenv("GITHUB_REPOSITORY", "safedep/pmg") + t.Setenv("GITHUB_REF_NAME", "feature/cool") + t.Setenv("GITHUB_HEAD_REF", "") + t.Setenv("GITHUB_SHA", "deadbeef1234567890") + t.Setenv("GITHUB_ACTOR", "octocat") + t.Setenv("GITHUB_REF", "refs/heads/feature/cool") + + resolver := newGithubActionsCIResolver() + + assert.Equal(t, controltowerv1.EndpointCIProvider_ENDPOINT_CI_PROVIDER_GITHUB_ACTIONS, resolver.Provider()) + assert.Equal(t, "9876543210", resolver.RunId()) + assert.Equal(t, "safedep/pmg", resolver.Repository()) + assert.Equal(t, "feature/cool", resolver.Branch()) + assert.Equal(t, "deadbeef1234567890", resolver.CommitSha()) + assert.Equal(t, "octocat", resolver.Actor()) + assert.Equal(t, "", resolver.PrNumber()) +} + +func TestGithubActionsResolverPRBranch(t *testing.T) { + t.Setenv("GITHUB_HEAD_REF", "fix/security-patch") + t.Setenv("GITHUB_REF_NAME", "123/merge") + t.Setenv("GITHUB_REF", "refs/pull/42/merge") + t.Setenv("GITHUB_RUN_ID", "111") + t.Setenv("GITHUB_REPOSITORY", "safedep/pmg") + t.Setenv("GITHUB_SHA", "abc") + t.Setenv("GITHUB_ACTOR", "user") + + resolver := newGithubActionsCIResolver() + + assert.Equal(t, "fix/security-patch", resolver.Branch(), "should prefer GITHUB_HEAD_REF for PRs") + assert.Equal(t, "42", resolver.PrNumber(), "should extract PR number from GITHUB_REF") +} + +func TestGithubActionsResolverMetadata(t *testing.T) { + t.Setenv("GITHUB_WORKFLOW", "CI") + t.Setenv("GITHUB_JOB", "build") + t.Setenv("GITHUB_RUN_ATTEMPT", "1") + t.Setenv("GITHUB_SERVER_URL", "https://github.com") + + resolver := newGithubActionsCIResolver() + metadata := resolver.Metadata() + + assert.Equal(t, "CI", metadata["workflow"]) + assert.Equal(t, "build", metadata["job"]) + assert.Equal(t, "1", metadata["run_attempt"]) + assert.Equal(t, "https://github.com", metadata["server_url"]) +} + +func TestGithubActionsResolverMetadataEmpty(t *testing.T) { + t.Setenv("GITHUB_WORKFLOW", "") + t.Setenv("GITHUB_JOB", "") + t.Setenv("GITHUB_RUN_ATTEMPT", "") + t.Setenv("GITHUB_SERVER_URL", "") + + resolver := newGithubActionsCIResolver() + assert.Nil(t, resolver.Metadata()) +} + +func TestGithubActionsResolverNonPRRef(t *testing.T) { + t.Setenv("GITHUB_HEAD_REF", "") + t.Setenv("GITHUB_REF_NAME", "main") + t.Setenv("GITHUB_REF", "refs/heads/main") + t.Setenv("GITHUB_RUN_ID", "222") + t.Setenv("GITHUB_REPOSITORY", "safedep/pmg") + t.Setenv("GITHUB_SHA", "def") + t.Setenv("GITHUB_ACTOR", "bot") + + resolver := newGithubActionsCIResolver() + + assert.Equal(t, "main", resolver.Branch(), "should use GITHUB_REF_NAME when GITHUB_HEAD_REF is empty") + assert.Equal(t, "", resolver.PrNumber(), "should be empty for non-PR ref") +} diff --git a/internal/audit/cloud_env_resolver_test.go b/internal/audit/cloud_env_resolver_test.go new file mode 100644 index 0000000..64ca3be --- /dev/null +++ b/internal/audit/cloud_env_resolver_test.go @@ -0,0 +1,36 @@ +package audit + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewCloudSinkCIResolverReturnsNilWhenNoCI(t *testing.T) { + t.Setenv("GITHUB_ACTIONS", "") + + resolver := newCloudSinkCIResolver() + assert.Nil(t, resolver) +} + +func TestNewCloudSinkCIResolverReturnsNilWhenPartialEnv(t *testing.T) { + t.Setenv("GITHUB_ACTIONS", "true") + t.Setenv("GITHUB_RUN_ID", "") + + resolver := newCloudSinkCIResolver() + assert.Nil(t, resolver) +} + +func TestNewCloudSinkCIResolverReturnsGitHub(t *testing.T) { + t.Setenv("GITHUB_ACTIONS", "true") + t.Setenv("GITHUB_RUN_ID", "12345") + t.Setenv("GITHUB_REPOSITORY", "safedep/pmg") + t.Setenv("GITHUB_REF_NAME", "main") + t.Setenv("GITHUB_SHA", "abc123") + t.Setenv("GITHUB_ACTOR", "dependabot[bot]") + + resolver := newCloudSinkCIResolver() + require.NotNil(t, resolver) + assert.Equal(t, "safedep/pmg", resolver.Repository()) +} diff --git a/internal/audit/cloud_sink.go b/internal/audit/cloud_sink.go index c8a36e2..29bbf68 100644 --- a/internal/audit/cloud_sink.go +++ b/internal/audit/cloud_sink.go @@ -4,7 +4,10 @@ import ( "context" "errors" "fmt" + "os" + "strings" + controltowerv1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/controltower/v1" "github.com/google/uuid" "github.com/safedep/dry/cloud/endpointsync" "github.com/safedep/dry/log" @@ -14,9 +17,12 @@ import ( type cloudSink struct { *SyncClientBundle invocationID string + ciResolver CloudSinkCIResolver + command string + workingDir string } -func newCloudSink(cfg *config.RuntimeConfig) (*cloudSink, error) { +func newCloudSink(cfg *config.RuntimeConfig, ciResolver CloudSinkCIResolver) (*cloudSink, error) { bundle, err := NewSyncClientBundle(cfg) if err != nil { return nil, err @@ -30,13 +36,28 @@ func newCloudSink(cfg *config.RuntimeConfig) (*cloudSink, error) { return nil, fmt.Errorf("failed to generate invocation ID: %w", err) } + wd, err := os.Getwd() + if err != nil { + if closeErr := bundle.Close(); closeErr != nil { + log.Warnf("failed to close sync client bundle after getwd failure: %v", closeErr) + } + return nil, fmt.Errorf("failed to get working directory: %w", err) + } + return &cloudSink{ SyncClientBundle: bundle, invocationID: invocationID.String(), + ciResolver: ciResolver, + workingDir: wd, }, nil } func (s *cloudSink) Handle(ctx context.Context, event AuditEvent) error { + if event.Type == EventTypeInstallStarted { + s.command = buildCommand(event.PackageManager, event.Args) + return nil + } + pmgEvents := s.translateToPmgEvents(event) if len(pmgEvents) == 0 { return nil @@ -50,6 +71,11 @@ func (s *cloudSink) Handle(ctx context.Context, event AuditEvent) error { toolEvent.SetPmgEvent(pmgEvent) toolEvent.SetInvocationId(s.invocationID) + // Invocation context (CI, command, working dir) is set once per + // execution on the session summary event to avoid redundancy. + if event.Type == EventTypeSessionComplete { + toolEvent.SetInvocationContext(s.buildInvocationContext()) + } if err := s.syncClient.Emit(ctx, toolEvent); err != nil { if errors.Is(err, endpointsync.ErrWALFull) { @@ -63,6 +89,39 @@ func (s *cloudSink) Handle(ctx context.Context, event AuditEvent) error { return nil } +func (s *cloudSink) buildInvocationContext() *controltowerv1.EndpointInvocationContext { + ctx := &controltowerv1.EndpointInvocationContext{} + ctx.SetCommand(s.command) + ctx.SetWorkingDirectory(s.workingDir) + + if s.ciResolver != nil { + ci := &controltowerv1.EndpointCIContext{} + ci.SetProvider(s.ciResolver.Provider()) + ci.SetRunId(s.ciResolver.RunId()) + ci.SetRepository(s.ciResolver.Repository()) + ci.SetBranch(s.ciResolver.Branch()) + ci.SetCommitSha(s.ciResolver.CommitSha()) + ci.SetActor(s.ciResolver.Actor()) + ci.SetPrNumber(s.ciResolver.PrNumber()) + if metadata := s.ciResolver.Metadata(); len(metadata) > 0 { + ci.SetMetadata(metadata) + } + ctx.SetCi(ci) + } + + return ctx +} + +func buildCommand(packageManager string, args []string) string { + if packageManager == "" { + return "" + } + if len(args) == 0 { + return packageManager + } + return packageManager + " " + strings.Join(args, " ") +} + // Close delegates to the embedded SyncClientBundle.Close(). func (s *cloudSink) Close() error { return s.SyncClientBundle.Close() diff --git a/internal/audit/cloud_sink_test.go b/internal/audit/cloud_sink_test.go index bef9df4..25fd94e 100644 --- a/internal/audit/cloud_sink_test.go +++ b/internal/audit/cloud_sink_test.go @@ -40,6 +40,7 @@ func newTestCloudSink(t *testing.T, transport endpointsync.EventTransport) *clou return &cloudSink{ SyncClientBundle: &SyncClientBundle{syncClient: syncClient}, invocationID: "test-invocation", + workingDir: t.TempDir(), } } @@ -98,3 +99,59 @@ func TestCloudSinkEmitAndSync(t *testing.T) { assert.Equal(t, 1, synced) assert.Equal(t, 1, len(transport.requests)) } + +func TestCloudSinkSetsInvocationContextOnSessionComplete(t *testing.T) { + transport := &mockTransport{} + + sink := newTestCloudSink(t, transport) + defer func() { + require.NoError(t, sink.Close()) + }() + + ctx := context.Background() + + err := sink.Handle(ctx, AuditEvent{ + Type: EventTypeInstallStarted, + Timestamp: time.Now(), + PackageManager: "npm", + Args: []string{"install", "express"}, + }) + require.NoError(t, err) + + err = sink.Handle(ctx, AuditEvent{ + Type: EventTypeMalwareBlocked, + Timestamp: time.Now(), + Message: "blocked malware package", + }) + require.NoError(t, err) + + err = sink.Handle(ctx, AuditEvent{ + Type: EventTypeSessionComplete, + Timestamp: time.Now(), + SessionData: &SessionData{ + PackageManager: "npm", + FlowType: FlowTypeGuard, + Outcome: OutcomeSuccess, + TotalAnalyzed: 1, + AllowedCount: 1, + }, + }) + require.NoError(t, err) + + synced, err := sink.syncClient.Sync(ctx) + require.NoError(t, err) + assert.Equal(t, 2, synced) + require.Equal(t, 1, len(transport.requests)) + + events := transport.requests[0].GetEvents() + require.Equal(t, 2, len(events)) + + malwareEvent := events[0] + assert.Nil(t, malwareEvent.GetInvocationContext(), "non-session events should not have invocation context") + + sessionEvent := events[1] + invCtx := sessionEvent.GetInvocationContext() + require.NotNil(t, invCtx, "session complete event must have invocation context") + assert.Contains(t, invCtx.GetCommand(), "npm") + assert.NotEmpty(t, invCtx.GetWorkingDirectory()) +}