mirror of
https://github.com/safedep/pmg.git
synced 2026-08-03 07:24:09 +02:00
feat: populate CI invocation context on cloud events (#304)
* feat: add CloudSinkEnvResolver interface with default implementation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add GitHub Actions environment resolver for cloud sink Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: populate invocation context with CI environment on cloud events Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: address lint errors in cloud sink tests Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * fix: fail fast on os.Getwd error instead of swallowing it Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
4540dafccc
commit
6087bc922f
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user